Aller au contenu

Déploiement zéro confiance avec Kubernetes

cover

L'utilisation de logiciels OpenSource écrits par des inconnus peut parfois être un peu effrayante. Encore plus lorsque je les déploie dans un environnement de production dans mon entreprise. Dans mon cas, j'ai créé un tout nouveau cluster Kubernetes pour héberger certains services privés sur mon réseau local et je voulais être certain qu'ils ne font rien de malveillant sur mon réseau.

Isoler les applications et les services

La première chose à faire est d'isoler chacun des services dans un namespace Kubernetes. Il s'agit d'une ressource centrale de Kubernetes qui permet d'isoler des groupes de ressources au sein d'un même cluster. Il est alors possible d'avoir un contrôle plus fin sur les accès, les permissions, le réseau sur les ressources.

Un Namespace peut être créé assez facilement avec la commande suivante :

kubectl create namespace <insert-namespace-name-here>

Ensuite, je peux lister les Namespace avec :

kubectl get namespaces
Résultat :
NAME              STATUS   AGE
default           Active   4h52m
kube-system       Active   4h52m
kube-public       Active   4h52m
kube-node-lease   Active   4h52m
tiwabbit-prod     Active   2s

Ignorez les Namespaces préfixés par kube- qui sont dédiés pour le control plane de Kubernetes

Je peux maintenant créer des ressources dans mon Namespace. Commençons par un Secret par exemple :

kubectl \
  --namespace tiwabbit-prod \
  create secret generic \
  db-credentials \
  --from-literal=username=tiwabbit \
  --from-literal=password=mysecurepassword

Si je liste tous les Secrets de mon cluster :

kubectl get secrets --all-namespaces
Résultat :
NAMESPACE       NAME                  TYPE                                  DATA   AGE
[...]
default         default-token-m59jl   kubernetes.io/service-account-token   3      4h58m
tiwabbit-prod   default-token-8zkr2   kubernetes.io/service-account-token   3      3m15s
tiwabbit-prod   db-credentials        Opaque                                2      49s

En théorie, seuls les Pods exécutés dans mon Namespace (tiwabbit-prod) peuvent monter ce secret et le lire.

Sécurisation RBAC

Par défaut, tous les Pods sans configuration spécifique utilisent le ServiceAccount default du namespace dans lequel ils s'exécutent. Ce dernier n'a aucun droit sur l'API Kubernetes ce qui est une bonne chose et ne devrait pas être modifié.

Cependant, dans certains scénarios, mon application peut devoir appeller l'API Kubernetes. Par exemple, s'il doit créer un batch à l'aide d'un Job Kubernetes. Prenons ce dernier exemple pour créer un ServiceAccount avec ces autorisations et l'attribuer à mon application Pod.

Je dois d'abord créer un nouveau ServiceAccount :

kubectl \
  --namespace tiwabbit-prod \
  create serviceaccount \
  my-application

Ensuite, j'ai besoin d'un Role qui implémente le niveau d'autorisation dont mon Pod a besoin. voici le manifeste :

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: my-application-batch-creator
  namespace: tiwabbit-prod
rules:
- apiGroups: []
  resources: [ pods, pods/status, pods/log ]
  verbs: [ get, list, watch ]

- apiGroups: [ batch ]
  resources: [ jobs ]
  verbs: [ create, get, list, watch, patch, update, delete ]

Enfin, je dois attribuer le Role à mon ServiceAccount en utilisant un RoleBinding avec la définition de manifeste suivante :

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: my-application-permissions
  namespace: tiwabbit-prod
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: my-application-batch-creator
subjects:
- kind: ServiceAccount
  name: my-application
  namespace: tiwabbit-prod

Créons maintenant un Pod avec le ServiceAccount et examinons les actions autorisées par le rôle. Voici un Déploiement manifeste :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-application
  namespace: tiwabbit-prod
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-application
  template:
    metadata:
      labels:
        app: my-application
    spec:
      serviceAccountName: my-application
      containers:
      - command:
        - sleep
        - 1d
        image: alpine:latest
        name: alpine

Entrer dans le Pod et suivre la documentation Kubernetes pour installer kubectl.

Vérifions si je peux lister Pods en interrogeant l'API Kubernetes :

kubectl get pods
Résultat :
NAME                             READY   STATUS    RESTARTS   AGE
my-application-df5b5cb75-8twc5   1/1     Running   0          5m21s

Ensuite, je devrais essayer de créer une exécution par batch avec un Job :

kubectl create job my-application-batch --image alpine:latest -- echo "Hello World"

Liste de tous les Jobs dans le Namespace :

kubectl get jobs
Résultat :
NAME                   COMPLETIONS   DURATION   AGE
my-application-batch   0/1           2s         2s

Lister les Pods dans le Namespace :

kubectl get pods
Résultat :
NAME                             READY   STATUS    RESTARTS   AGE
my-application-df5b5cb75-8twc5   1/1     Running   0          11m
my-application-batch-f2pf6       1/1     Running   0          3s

Mon Job est terminé et je peux le supprimer avec :

kubectl delete job/my-application-batch

Conclusions

Si le processus de mon Pod n'a pas besoin de communiquer avec l'API Kubernetes (pour créer, interroger, supprimer des ressources), utilisez le default ServiceAccount qui ne donne aucune autorisation à mon Pod. Dans d'autres cas, utilisez un ServiceAccount personnalisé pour chacune de mes applications et plusieurs Role et RoleBinding pour chacun de mes cas d'utilisation.

Empêcher l'utilisation de l'utilisateur root ou l'escalade de privilége

Kubernetes autorise par défaut plusieurs options de sécurité pouvant être appliquées aux pods et aux conteneurs sous-jacents. La plupart d'entre eux peuvent être configurés avec le bloc SecurityContext comme suit :

apiVersion: v1
kind: Pod
metadata:
  name: my-secure-pod
spec:
  securityContext: {}
  containers:
  - name: my-secure-container
    image: nginx:latest
    securityContext: {}
  - name: my-second-secure-container
    image: busybox:latest
    command: ["sleep", "infinity"]
    securityContext: {}

Évidemment, de telles options peuvent être ajoutées à un StatefulSet ou Deployment.

Exécuter le pod en tant que non root

La configuration la plus simple pour sécuriser le Pod consiste à remplacer l'UID et le GID de l'utilisateur exécutant le processus principal. De cette façon, quel que soit l'utilisateur par défaut dans l'image, Kubernetes le contournera. Un UID et un GID supérieurs ou égaux à 1000 doivent être utilisés.

Trois paramètres peuvent être utilisés :

  • runAsUser : Permet de changer l'UID du processus par défaut
  • runAsGroup : Permet de changer le GID du processus par défaut
  • fsGroup : si spécifié, l'utilisateur sera également dans ce groupe et tous les fichiers et répertoires créés prendront ce GID comme propriétaire.
  • Ce dernier paramètre peut augmenter le temps de montage d'un volume externe donné car Kubernetes garantit que les fichiers appartiennent au groupe défini par fsGroup. Résultant en un chmod -R de tout le système de fichiers.
  • fsGroupChangePolicy peut être utilisé pour modifier ce comportement avec ces valeurs :
    • OnRootMismatch : vérifie uniquement le répertoire racine et modifiez tous les systèmes de fichiers si le groupe ne correspond pas
    • Always : c'est dans le nom de l'option
apiVersion: v1
kind: Pod
metadata:
  name: my-secure-pod
spec:
  securityContext:
    runAsUser: 1000
    runAsGroup: 1000
    fsGroup: 2000
    fsGroupChangePolicy: OnRootMismatch
  containers:
  - name: my-secure-container
    image: busybox:latest
    command: ["sleep", "infinity"]
    securityContext:
      runAsUser: 2000
      runAsGroup: 2000

L'application de l'exécution en tant que non root peut être obtenue avec le paramètre runAsNonRoot :

apiVersion: v1
kind: Pod
metadata:
  name: my-secure-pod
spec:
  securityContext:
    runAsNonRoot: true
  containers:
  - name: my-secure-container
    image: busybox:latest
    command: ["sleep", "infinity"]
    securityContext: {}

Utiliser le système de fichiers en lecture seule

Empêcher toute opération d'écriture/mise à jour/suppression on le système de fichiers racine peut empêcher un processus malveillant (ou une application piratée) de profiter de l'environnement du conteneur et de le modifier. L'option readOnlyRootFilesystem permet de réaliser cela :

apiVersion: v1
kind: Pod
metadata:
  name: my-secure-pod
spec:
  securityContext: {}
  containers:
  - name: my-secure-container
    image: busybox:latest
    command: ["sleep", "infinity"]
    securityContext:
      readOnlyRootFilesystem: true

Si l'application doit écrire des données temporaires ou d'application pendant l'exécution, je peux toujours créer un volume EmptyDir et le monter à l'intérieur du conteneur :

apiVersion: v1
kind: Pod
metadata:
  name: my-secure-pod
spec:
  securityContext: {}
  containers:
  - name: my-secure-container
    image: busybox:latest
    command: ["sleep", "infinity"]
    securityContext:
      readOnlyRootFilesystem: true
    volumeMounts:
    - mountPath: /data
      name: my-data
  volumes:
  - name: my-data
    emptyDir: {}

Empêcher l'escalade privilégiée

Le noyau Linux expose la fonction de bas niveau pour qu'un processus à modifier soit actuel UID ou GID. Par exemple, en utilisant les fonctions privatives [setuid][linux-setuid-wikipedia] ou setgid.

L'utilisation de l'option allowPrivilegeEscalation peut empêcher que cela ne se produise.

apiVersion: v1
kind: Pod
metadata:
  name: my-secure-pod
spec:
  securityContext: {}
  containers:
  - name: my-secure-container
    image: busybox:latest
    command: ["sleep", "infinity"]
    securityContext:
      allowPrivilegeEscalation: false

Suppression de toutes les capabilities

En ce qui concerne l'escalade privilégiée, Linux donne Capabilities au processus leur permettant de faire des appels système hautement privilégiés. Comme l'écoute sur des ports réseau end dessous de 1024.

Nous ne voulons donner à notre processus que les capacités nécessaires à son exécution. Dans le cas de l'exemple on peut avoir :

apiVersion: v1
kind: Pod
metadata:
  name: my-secure-pod
spec:
  securityContext: {}
  containers:
  - name: my-second-secure-container
    image: busybox:latest
    command: ["sleep", "infinity"]
    securityContext:
      capabilities:
        drop:
        - ALL
        add:
        - NET_BIND_SERVICE

Renforcement de la communication réseau

Dans le monde des pods, par défaut, tous les pods de chaque espace de noms peuvent communiquer entre eux. Si vous avez plusieurs applications ou même plusieurs clients sur le même cluster Kubernetes, vous ne devez pas les autoriser à communiquer (du moins pas par défaut).

C'est pourquoi, des NetworkPolicies doivent être créés avec deny all par défaut. Ouvrez ensuite des connexions point à point si les services/clients doivent communiquer entre eux.

La configuration par défaut de deny all ressemblera à ceci :

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
spec:
  podSelector: {}
  policyTypes:
  - Ingress

Ensuite, si vous avez une application tierce qui a besoin d'accéder à vos pods d'application :

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: my-namespace
spec:
  podSelector:
    matchLabels:
      application: my-application
      component: api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              project: my-friend-namespace
        - podSelector:
            matchLabels:
              application: his-application
      ports:
        - protocol: TCP
          port: 8080