11 min read

Kubernetes et OPA Gatekeeper, sécuriser votre cluster !

Dans cet article, nous allons plonger dans l'univers de Kubernetes et découvrir comment il est possible de contrôler les déploiements en utilisant OPA Gatekeeper. OPA Gatekeeper est une solution Open Source qui permet de définir et d'appliquer des politiques de sécurité pour les déploiements Kubernetes. Dans cet article, nous explorerons en détail comment OPA Gatekeeper fonctionne, comment il s'intègre avec Kubernetes et comment il peut nous aider à maintenir un environnement de déploiement sécurisé et conforme. Préparez-vous à découvrir les possibilités passionnantes qu'offre cette combinaison puissante de Kubernetes et d'OPA Gatekeeper pour contrôler vos déploiements comme jamais auparavant

#Kubernetes

#OPA Gatekeeper

#Cybersecurity

Article publié le 1 juil. 2023

·

11 min de lecture

  • ⚙️ Le cluster est sous Kubernetes 1.26 et installé avec Minikube en utilisant Docker.
  • ⚙️ Le client Kubernetes utilisé est kubectl à la version 1.26.

Dans le monde complexe de Kubernetes, il est essentiel de maintenir des normes strictes en matière de sécurité et de performances. Deux aspects cruciaux de cette gestion sont la vérification des labels et la mise en place de limites CPU et RAM. Dans cet article, nous explorerons en détail ces deux aspects clés et comment OPA Gatekeeper peut nous aider à les contrôler.

🛡️ Avec OPA Gatekeeper, vous pouvez créer des politiques de sécurité personnalisées qui contrôlent et valident automatiquement les labels attribués à vos ressources. De cette manière, vous pouvez assurer une organisation efficace de vos déploiements et vous conformer aux normes spécifiques de votre entreprise.

Dans les sections suivantes, nous vous guiderons à travers les étapes nécessaires pour intégrer et configurer OPA Gatekeeper dans votre infrastructure Kubernetes. Nous vous fournirons des exemples concrets de politiques de sécurité et de limites CPU, extraites directement du site de GateKeeper.

Quel est l'intérêt d'utiliser ce système d'admission ? 🤔

Bloquer les déploiements mal configurés ❌

Les labels sont des étiquettes attribuées aux ressources Kubernetes telles que les pods, les services et les déploiements. Ils fournissent des métadonnées supplémentaires qui aident à organiser et à filtrer les ressources. Avec OPA Gatekeeper, nous pouvons définir des politiques qui exigent que certaines ressources aient des labels spécifiques.

💡 Par exemple, nous pouvons définir une politique qui exige que tous les déploiements dans un cluster aient un label "owner" spécifiant la personne qui à crée une ressource. Grâce à OPA Gatekeeper, ces politiques peuvent être appliquées de manière centralisée, garantissant ainsi que toutes les ressources sont correctement étiquetées.

Bloquer les pods sans limites de ressources ❌

En ce qui concerne les limites CPU et RAM, Kubernetes offre des mécanismes pour gérer les ressources allouées aux conteneurs. Les limits permettent de spécifier la quantité maximale de puissance de calcul qu'un conteneur peut consommer. Cela aide à éviter qu'un seul conteneur ne monopolise toutes les ressources du cluster. OPA Gatekeeper peut être utilisé pour définir des politiques qui imposent des limites CPU pour les déploiements.

💡 Par exemple, nous pouvons définir une politique qui exige que tous les déploiements aient une limite CPU maximale de 100M et une limite de RAM de 1Gi, empêchant ainsi les déploiements de consommer trop de ressources et garantissant une répartition équitable des ressources entre les applications.

Configuration du cluster pour Gatekeeper ⚙️

Activation des contrôleurs d'admission

Pour pouvoir contrôler les ressources qui seront déployées dans notre cluster, on devra au préalable vérifier que les Admission Controllers sont activés. On peut vérifier lesquels sont déjà activés par défaut avec la commande :

kube-apiserver -h | grep enable-admission-plugins

⭐️ Pour que Gatekeeper fonctionne, il faut que MutatingAdmissionWebhook et ValidatingAdmissionWebhook soient activés. S'ils ne sont pas activés, voir dans la documentation comment les activés.

💡 Ici la documentation officielle de Kubernetes sur le MutatingAdmissionWebhook et le ValidatingAdmissionWebhook. Mais concrétement, ce sont des types de controllers d'admission intégrer à Kubernetes qui permettent d'attendre un signal (Webhook) avant d'autoriser la création/màj/suppression d'une ressource Kubernetes (Pods, ReplicaSet, ...).

Installation de Gatekeeper

Pour installer Gatekeeper on peut directement utiliser la commande suivante:

kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/deploy/gatekeeper.yaml

⭐️ Les pods seront créés dans le namespace gatekeeper-system.

On peut également utiliser helm pour l'installation:

helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts --force-update
helm install gatekeeper/gatekeeper --name-template=gatekeeper --namespace gatekeeper-system --create-namespace

Une fois l'installation effectué, on devra attendre que les pods se lancent: waiting_pods.png

Configuration de Gatekeeper

Les ConstraintsTemplates sont les éléments de base qui définissent les politiques de sécurité dans Gatekeeper. Les templates sont écrits en Rego, qui est un langage déclaratif supportant Open Policy Agent (OPA). Ils fournissent un modèle pour créer des contraintes spécifiques, permettant ainsi de personnaliser les politiques en fonction de vos besoins. Chaque ConstraintsTemplate comprend plusieurs contraintes (Constraints), qui sont les règles de conformité que vous souhaitez appliquer.

💡 Les divers modèles de ConstraintTemplates sont disponibles sur le site de Gatekeeper !

Les Templates sont des objets Kubernetes utilisés pour créer des instances de ConstraintTemplates. Ils permettent de générer facilement des politiques de sécurité spécifiques à des cas d'utilisation particuliers. En utilisant les Templates, vous pouvez rapidement déployer et réutiliser des politiques de sécurité cohérentes dans votre cluster.

Mise en place de nos politiques de sécurité ✅

Imposer un label aux déploiements

Pour l'exemple, nous avons décider dans notre politique de déploiement que tous nos déploiements doivent contenir le label owner. Ce label devra contenir le prénom de la perssone qui crée le déploiement en respectant le format: [owner].mycompany.demo. Si le label n'est pas présent ou que le format n'est pas respecté, alors le déploiement sera refusé.

On crée donc la ConstraintTemplate qui contiendra notre condition :

apiVersion: templates.gatekeeper.sh/v1
  kind: ConstraintTemplate
  metadata:
    name: k8srequiredlabels
  spec:
    crd:
      spec:
        names:
          kind: K8sRequiredLabels
        validation:
          openAPIV3Schema:
            type: object
            properties:
              message:
                type: string
              labels:
                type: array
                items:
                  type: object
                  properties:
                    key:
                      type: string
                    allowedRegex:
                      type: string
    targets:
      - target: admission.k8s.gatekeeper.sh
        rego: |
          package k8srequiredlabels

          get_message(parameters, _default) = msg {
            not parameters.message
            msg := _default
          }

          get_message(parameters, _default) = msg {
            msg := parameters.message
          }

          violation[{"msg": msg, "details": {"missing_labels": missing}}] {
            provided := {label | input.review.object.metadata.labels[label]}
            required := {label | label := input.parameters.labels[_].key}
            missing := required - provided
            count(missing) > 0
            def_msg := sprintf("you must provide labels: %v", [missing])
            msg := get_message(input.parameters, def_msg)
          }

          violation[{"msg": msg}] {
            value := input.review.object.metadata.labels[key]
            expected := input.parameters.labels[_]
            expected.key == key
            expected.allowedRegex != ""
            not re_match(expected.allowedRegex, value)
            def_msg := sprintf("Label <%v: %v> does not satisfy allowed regex: %v", [key, value, expected.allowedRegex])
            msg := get_message(input.parameters, def_msg)
          }

On créer un fichier label-env-constraint-template.yml, on y colle le contenu ci-dessus et on applique à notre cluster :

kubectl apply -f label-env-constraint-template.yml
  • Puis,la Constraint associé qui va définir que le nom exact du label qui doit être systématiquement présent :

    apiVersion: constraints.gatekeeper.sh/v1beta1
      kind: K8sRequiredLabels
      metadata:
        name: dep-must-have-owner
      spec:
        match:
          kinds:
            - apiGroups: ["apps"]
              kinds: ["Deployment"]
        parameters:
          message: "Les déploiements doivent contenir le label 'owner' en respectant la syntaxe [owner].mycompany.demo"
          labels:
            - key: owner
              allowedRegex: "^[a-zA-Z]+.mycompany.demo$"
    

    On créer un fichier label-owner-constraint.yml, on y colle le contenu ci-dessus et on applique à notre cluster :

    kubectl apply -f label-owner-constraint.yml
    

Une fois les ressources crées, notre cluster est maintenant prêt à interrompre les déploiements qui ne posséderont pas le label owner ! 🚫

Ci-dessous, le message affiché lorsqu'un déploiement ne respecte pas la condition: deployement_disallowed.png

Imposer des limites CPU et RAM aux pods

Comme pour la configuration du label, on devra dans un premier temps créer la ConstraintTemplate suivante:

apiVersion: templates.gatekeeper.sh/v1
  kind: ConstraintTemplate
  metadata:
    name: k8scontainerlimits
    annotations:
      metadata.gatekeeper.sh/title: "Container Limits"
      metadata.gatekeeper.sh/version: 1.0.0
  spec:
    crd:
      spec:
        names:
          kind: K8sContainerLimits
        validation:
          openAPIV3Schema:
            type: object
            properties:
              exemptImages:
                type: array
                items:
                  type: string
              cpu:
                type: string
              memory:
                type: string
    targets:
      - target: admission.k8s.gatekeeper.sh
        rego: |
          package k8scontainerlimits

          import data.lib.exempt_container.is_exempt

          missing(obj, field) = true {
            not obj[field]
          }

          missing(obj, field) = true {
            obj[field] == ""
          }

          canonify_cpu(orig) = new {
            is_number(orig)
            new := orig * 1000
          }

          canonify_cpu(orig) = new {
            not is_number(orig)
            endswith(orig, "m")
            new := to_number(replace(orig, "m", ""))
          }

          canonify_cpu(orig) = new {
            not is_number(orig)
            not endswith(orig, "m")
            re_match("^[0-9]+(\\.[0-9]+)?$", orig)
            new := to_number(orig) * 1000
          }

          # 10 ** 21
          mem_multiple("E") = 1000000000000000000000 { true }

          # 10 ** 18
          mem_multiple("P") = 1000000000000000000 { true }

          # 10 ** 15
          mem_multiple("T") = 1000000000000000 { true }

          # 10 ** 12
          mem_multiple("G") = 1000000000000 { true }

          # 10 ** 9
          mem_multiple("M") = 1000000000 { true }

          # 10 ** 6
          mem_multiple("k") = 1000000 { true }

          # 10 ** 3
          mem_multiple("") = 1000 { true }

          # Kubernetes accepts millibyte precision when it probably shouldn't.
          # https://github.com/kubernetes/kubernetes/issues/28741
          # 10 ** 0
          mem_multiple("m") = 1 { true }

          # 1000 * 2 ** 10
          mem_multiple("Ki") = 1024000 { true }

          # 1000 * 2 ** 20
          mem_multiple("Mi") = 1048576000 { true }

          # 1000 * 2 ** 30
          mem_multiple("Gi") = 1073741824000 { true }

          # 1000 * 2 ** 40
          mem_multiple("Ti") = 1099511627776000 { true }

          # 1000 * 2 ** 50
          mem_multiple("Pi") = 1125899906842624000 { true }

          # 1000 * 2 ** 60
          mem_multiple("Ei") = 1152921504606846976000 { true }

          get_suffix(mem) = suffix {
            not is_string(mem)
            suffix := ""
          }

          get_suffix(mem) = suffix {
            is_string(mem)
            count(mem) > 0
            suffix := substring(mem, count(mem) - 1, -1)
            mem_multiple(suffix)
          }

          get_suffix(mem) = suffix {
            is_string(mem)
            count(mem) > 1
            suffix := substring(mem, count(mem) - 2, -1)
            mem_multiple(suffix)
          }

          get_suffix(mem) = suffix {
            is_string(mem)
            count(mem) > 1
            not mem_multiple(substring(mem, count(mem) - 1, -1))
            not mem_multiple(substring(mem, count(mem) - 2, -1))
            suffix := ""
          }

          get_suffix(mem) = suffix {
            is_string(mem)
            count(mem) == 1
            not mem_multiple(substring(mem, count(mem) - 1, -1))
            suffix := ""
          }

          get_suffix(mem) = suffix {
            is_string(mem)
            count(mem) == 0
            suffix := ""
          }

          canonify_mem(orig) = new {
            is_number(orig)
            new := orig * 1000
          }

          canonify_mem(orig) = new {
            not is_number(orig)
            suffix := get_suffix(orig)
            raw := replace(orig, suffix, "")
            re_match("^[0-9]+(\\.[0-9]+)?$", raw)
            new := to_number(raw) * mem_multiple(suffix)
          }

          violation[{"msg": msg}] {
            general_violation[{"msg": msg, "field": "containers"}]
          }

          violation[{"msg": msg}] {
            general_violation[{"msg": msg, "field": "initContainers"}]
          }

          # Ephemeral containers not checked as it is not possible to set field.

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            cpu_orig := container.resources.limits.cpu
            not canonify_cpu(cpu_orig)
            msg := sprintf("container <%v> cpu limit <%v> could not be parsed", [container.name, cpu_orig])
          }

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            mem_orig := container.resources.limits.memory
            not canonify_mem(mem_orig)
            msg := sprintf("container <%v> memory limit <%v> could not be parsed", [container.name, mem_orig])
          }

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            not container.resources
            msg := sprintf("container <%v> has no resource limits", [container.name])
          }

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            not container.resources.limits
            msg := sprintf("container <%v> has no resource limits", [container.name])
          }

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            missing(container.resources.limits, "cpu")
            msg := sprintf("container <%v> has no cpu limit", [container.name])
          }

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            missing(container.resources.limits, "memory")
            msg := sprintf("container <%v> has no memory limit", [container.name])
          }

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            cpu_orig := container.resources.limits.cpu
            cpu := canonify_cpu(cpu_orig)
            max_cpu_orig := input.parameters.cpu
            max_cpu := canonify_cpu(max_cpu_orig)
            cpu > max_cpu
            msg := sprintf("container <%v> cpu limit <%v> is higher than the maximum allowed of <%v>", [container.name, cpu_orig, max_cpu_orig])
          }

          general_violation[{"msg": msg, "field": field}] {
            container := input.review.object.spec[field][_]
            not is_exempt(container)
            mem_orig := container.resources.limits.memory
            mem := canonify_mem(mem_orig)
            max_mem_orig := input.parameters.memory
            max_mem := canonify_mem(max_mem_orig)
            mem > max_mem
            msg := sprintf("container <%v> memory limit <%v> is higher than the maximum allowed of <%v>", [container.name, mem_orig, max_mem_orig])
          }
        libs:
          - |
            package lib.exempt_container

            is_exempt(container) {
                exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", [])
                img := container.image
                exemption := exempt_images[_]
                _matches_exemption(img, exemption)
            }

            _matches_exemption(img, exemption) {
                not endswith(exemption, "*")
                exemption == img
            }

            _matches_exemption(img, exemption) {
                endswith(exemption, "*")
                prefix := trim_suffix(exemption, "*")
                startswith(img, prefix)
            }

⭐️ Cette configuration est extraite de la documentation de GateKeeper.

On applique la ressource à notre cluster:

kubectl apply -f resources-limits-constraint-template.yml

On peut maintenant créer notre Constraint où l'on déterminera selon le besoin, les limites en CPU et en RAM qu'on souhaite établir:

apiVersion: constraints.gatekeeper.sh/v1beta1
  kind: K8sContainerLimits
  metadata:
    name: pod-must-have-limits
  spec:
    match:
      kinds:
        - apiGroups: [""]
          kinds: ["Pod"]
    parameters:
      cpu: "200m"
      memory: "1Gi"

On applique à notre cluster:

kubectl apply -f resources-limits-constraint.yml

Notre cluster est maintenant configuré pour bloquer la création des ressources Pods s'ils ne possèdent pas de limits définies ainsi que s'ils ne respectent pas les limits configurées ! 🚫

Ci-dessous, les messages affichés si les limits ne sont pas présentes ainsi que si les limits ne sont pas correctes: pod_disallowed.png

Le mot de la fin ⭐️

Voilà, nous avons vu comment utiliser Gatekeeper pour la mise en place de politiques de sécurité dans notre cluster. Ici nous avons vu deux exemples, mais Gatekeeper peut bien évidemment couvrir un spectre plus large. Ce contrôleur constitue une barrière de protection supplémentaire avant qu'une ressoure Kubernetes ne soit déployée dans notre cluster.

💡 Ces politiques peuvent être personnalisées en fonction des besoins, la documentation se trouve sur le site officiel de Gatekeeper où se trouve les librairies par défaut.

Malgrès les possiblitées de blocage offertes par GateKeeper, il est cependant nécessaire pour une sécurité optimale de configurer en amont les politiques d'authentifications (User/Service Account) et d'autorisations (RBAC).

Auteur Billy PAYET

Billy PAYET

Billy est un expert en DevOps qui a une solide expérience en automatisation et en intégration continue. Il utilise des outils tels que Ansible, Jenkins et Docker pour améliorer les processus de déploiement et de livraison des applications. Il est passionné par l'amélioration continue et aider les équipes à atteindre leurs objectifs de qualité et de performance.