Aller au contenu

Durcir un cluster Kubernetes : PSS, NetworkPolicies & Kyverno

Objectifs pédagogiques

  • Identifier la surface d'attaque d'un cluster et les référentiels de durcissement
  • Imposer le standard restricted aux workloads avec Pod Security Admission
  • Appliquer le moindre privilège au RBAC et cloisonner le réseau par défaut
  • Traduire la PSSI en politiques Kyverno bloquantes, testées dès la CI
  • N'admettre que les images signées par la pipeline
  • Sortir les secrets de Git en clair et prouver la conformité du cluster

Ce qu'un attaquant cherche dans un cluster

  • Un pod compromis qui tourne en root, avec des capabilities ou un accès à l'hôte
  • Un token de ServiceAccount monté par défaut et doté de droits excessifs
  • Un réseau plat : par défaut, tout pod peut joindre tout pod de tout namespace
  • Des secrets lisibles par trop de monde, en clair dans Git ou dans etcd
  • Une image non contrôlée, tirée d'un registre quelconque avec le tag latest
  • Le plan de contrôle : API server, kubelet et etcd mal configurés

Les référentiels

Référentiel Ce qu'il apporte
OWASP Kubernetes Top 10 Les dix risques majeurs : workloads non sécurisés, RBAC trop permissif, absence de NetworkPolicies, secrets mal gérés…
NSA/CISA Kubernetes Hardening Guide Recommandations opérationnelles : pods non-root, cloisonnement réseau, authentification, journalisation
CIS Kubernetes Benchmark Contrôles vérifiables du plan de contrôle, des nœuds et des politiques
Guide de durcissement k3s Adaptation du CIS à k3s, dont les composants sont embarqués dans un seul binaire

Défense en profondeur

Couche Contrôle Outil
Nœud OS durci, accès restreint Ansible, CIS Ubuntu
Plan de contrôle Configuration conforme, chiffrement des secrets Guide k3s, kube-bench
Identités RBAC au moindre privilège kubectl auth can-i
Admission Standards de sécurité, politiques métier, signatures PSA, Kyverno
Réseau Refus par défaut, flux explicites NetworkPolicies
Runtime Détection de comportements anormaux Falco

Aucune couche n'est suffisante seule : chacune limite l'impact de l'échec d'une autre


Sécuriser les workloads


Pod Security Standards

Profil Intention Exemples d'interdictions
privileged Aucune restriction Aucune (composants système uniquement)
baseline Bloquer les escalades connues privileged, hostNetwork, hostPID, hostPath, capabilities ajoutées
restricted Bonnes pratiques de durcissement En plus : root, élévation de privilèges, capabilities non supprimées, seccomp absent, volumes non autorisés

Les workloads applicatifs visent restricted, les namespaces système restent sur leur propre profil


Pod Security Admission

Contrôleur d'admission intégré à Kubernetes, configuré par labels de namespace

apiVersion: v1
kind: Namespace
metadata:
  name: securepay
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted
  • enforce : rejette les Pods non conformes
  • audit : accepte et inscrit la violation dans les journaux d'audit
  • warn : accepte et renvoie un avertissement à l'utilisateur (dont pour les Deployments)

Une Deployment non conforme est acceptée avec un avertissement : ce sont ses Pods qui sont refusés (événement FailedCreate sur le ReplicaSet)


securityContext conforme à restricted

spec:
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true
    runAsUser: 1001
    runAsGroup: 1001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: api
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}

readOnlyRootFilesystem n'est pas exigé par restricted, mais empêche un attaquant de modifier le binaire ou de déposer un outil


RBAC au moindre privilège


Principes

  • Une identité par composant : un ServiceAccount dédié par application, jamais default
  • Role et RoleBinding dans le namespace plutôt que ClusterRole globale
  • Aucun cluster-admin hors administrateurs, aucun verbe ou ressource *
  • Pas de token monté si l'application n'appelle pas l'API (automountServiceAccountToken: false)

Droits dangereux, même s'ils semblent anodins :

  • get, list, watch sur secrets : lecture de tous les secrets du namespace
  • create sur pods : un pod peut monter n'importe quel secret ou ServiceAccount du namespace
  • escalate, bind, impersonate : contournement direct du RBAC

Auditer les droits effectifs

# Tout ce que l'application peut faire dans son namespace
kubectl auth can-i --list -n securepay \
  --as=system:serviceaccount:securepay:securepay-api

# Questions ciblées
kubectl auth can-i get secrets -n securepay \
  --as=system:serviceaccount:securepay:securepay-api
kubectl auth can-i create pods -n securepay \
  --as=system:serviceaccount:securepay:securepay-api

# Qui est cluster-admin ?
kubectl get clusterrolebindings -o wide | grep cluster-admin

Réponse attendue pour l'API SecurePay : no partout, elle n'a aucun besoin de l'API Kubernetes


Cloisonner le réseau


Refus par défaut

Sans NetworkPolicy, le réseau d'un cluster est plat : un pod compromis peut joindre toutes les bases et toutes les API internes

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: securepay
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
  • Tous les pods du namespace sont sélectionnés, aucune règle n'autorise de flux
  • Le trafic entrant et sortant est bloqué, y compris le DNS
  • Nécessite un CNI ou un contrôleur qui applique les NetworkPolicies : c'est le cas de k3s

Rouvrir le DNS

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: securepay
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }

Rouvrir l'ingress et le scrape

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-traefik-and-prometheus
  namespace: securepay
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: securepay-api
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
          podSelector:
            matchLabels: { app.kubernetes.io/name: traefik }
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: monitoring }
          podSelector:
            matchLabels: { app.kubernetes.io/name: prometheus }
      ports:
        - { protocol: TCP, port: 3000 }

Le port est celui du pod (3000), pas celui du Service : la règle s'applique après la traduction d'adresse


Prouver l'étanchéité

# Depuis un namespace non autorisé : doit expirer
kubectl run netcheck -n default --rm -it --restart=Never \
  --image=curlimages/curl:<version> -- \
  curl -sS -m 5 http://securepay-api.securepay.svc.cluster.local:<port>/health

# Depuis Traefik (chemin nominal) : doit répondre
curl -sS http://<IP de la VM>/health
  • Un namespaceSelector et un podSelector dans le même élément de from forment un ET
  • Deux éléments distincts de la liste from forment un OU
  • Une erreur d'indentation transforme « Traefik du namespace kube-system » en « tout le namespace kube-system »

Policy as Code à l'admission


Pourquoi aller au-delà de PSA

Pod Security Admission couvre la sécurité du pod, pas les règles propres à l'organisation :

  • Interdire le tag latest et n'autoriser que les registres de l'entreprise
  • Exiger requests, limits et probes pour la stabilité de la plateforme
  • N'admettre que les images signées par la pipeline officielle
  • Imposer des labels de traçabilité (équipe, application, version)

Un moteur de politiques traduit ces exigences de la PSSI en règles d'admission bloquantes, versionnées et testables


Kyverno ou OPA Gatekeeper

Kyverno OPA Gatekeeper
Langage YAML Kubernetes et expressions CEL Rego
Courbe d'apprentissage Faible pour une équipe Kubernetes Plus forte, langage dédié
Validation, mutation, génération Oui Validation et mutation
Vérification de signatures d'images Native (Cosign, Notary) Via composants externes
Tests hors cluster CLI kyverno apply et kyverno test gator, opa test
Écosystème au-delà de Kubernetes Limité OPA partout (CI, API, IaC)

Pour aller plus loin sur Rego : Définir des politiques de sécurité avec OPA


Kyverno en 2026 : les politiques CEL

  • ValidatingPolicy et ImageValidatingPolicy (policies.kyverno.io/v1) sont les types recommandés
  • Leurs expressions utilisent CEL, le langage des ValidatingAdmissionPolicies natives de Kubernetes
  • ClusterPolicy et Policy (kyverno.io/v1) sont dépréciées depuis Kyverno 1.19, leur suppression est annoncée
  • Le guide officiel de migration associe validate à ValidatingPolicy et verifyImages à ImageValidatingPolicy

Les exemples de ce cours utilisent uniquement les politiques CEL


Une ValidatingPolicy

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: securepay-pod-baseline
spec:
  validationActions: [Deny]
  matchConstraints:
    namespaceSelector:
      matchLabels: { kubernetes.io/metadata.name: securepay }
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  variables:
    - name: containers
      expression: "object.spec.containers + object.spec.?initContainers.orValue([])"
  validations:
    - message: "Tag latest interdit : référencez une version ou un digest"
      expression: >-
        variables.containers.all(c, c.image.contains('@') ||
          (c.image.split('/')[size(c.image.split('/')) - 1].contains(':')
           && !c.image.endsWith(':latest')))

Les règles écrites pour les Pods sont aussi appliquées aux Deployments, StatefulSets et Jobs (autogen) : l'erreur remonte dès la création de la Deployment


Audit avant Deny

spec:
  validationActions: [Audit]     # puis [Deny] une fois les violations corrigées
kubectl get policyreports -n securepay
kubectl describe policyreport -n securepay <nom>
  • Audit : la ressource est admise, la violation est consignée dans un PolicyReport
  • Deny : la ressource est refusée à l'admission avec le message de la règle
  • Warn : la ressource est admise avec un avertissement côté client

Démarche : déployer en Audit, mesurer, corriger, puis passer en Deny sans surprise pour les équipes


Shift left : tester les politiques en CI

# .gitlab-ci.yml de securepay-platform (extrait)
policies:test:
  stage: validate
  image: alpine:<version>
  variables:
    KYVERNO_VERSION: "<version>"           # épinglez une version vérifiée
  before_script:
    - apk add --no-cache curl helm
    - curl -sSLO "https://github.com/kyverno/kyverno/releases/download/v${KYVERNO_VERSION}/kyverno-cli_v${KYVERNO_VERSION}_linux_x86_64.tar.gz"
    - curl -sSLO "https://github.com/kyverno/kyverno/releases/download/v${KYVERNO_VERSION}/checksums.txt"
    - grep "linux_x86_64.tar.gz$" checksums.txt | sha256sum -c -
    - tar -xzf kyverno-cli_*.tar.gz kyverno && mv kyverno /usr/local/bin/
  script:
    - kyverno test tests/policies/
    - helm template securepay-api charts/securepay-api -n securepay > rendered.yaml
    - kyverno apply policies/securepay-pod-baseline.yaml --resource rendered.yaml
  • kyverno apply sort en erreur si une ressource viole une politique : la Merge Request est bloquée
  • Rangez les tests hors du dossier synchronisé par ArgoCD : un dossier mêlant politiques et ressources de test fait charger zéro politique à kyverno apply, sans erreur

N'admettre que les images signées

apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: verify-securepay-signature
spec:
  validationActions: [Deny]
  matchConstraints:
    namespaceSelector:
      matchLabels: { kubernetes.io/metadata.name: securepay }
    resourceRules:
      - { apiGroups: [""], apiVersions: ["v1"], operations: ["CREATE", "UPDATE"], resources: ["pods"] }
  matchImageReferences:
    - glob: "registry.gitlab.com/<groupe>/securepay-api*"
  attestors:
    - name: pipeline
      cosign:
        keyless:
          identities:
            - issuer: "https://gitlab.com"
              subject: "https://gitlab.com/<groupe>/securepay-api//.gitlab-ci.yml@refs/heads/main"
  validations:
    - message: "Image non signée par la pipeline main de securepay-api"
      expression: >-
        images.containers.map(image, verifyImageSignatures(image, [attestors.pipeline])).all(e, e > 0)

Les subtilités de la vérification

  • Identité : le double / avant .gitlab-ci.yml fait partie du sujet du certificat GitLab
  • Digest : par défaut, une image sans digest est refusée ; promouvez par digest dans le dépôt GitOps
  • Mutation : Kyverno peut remplacer le tag par le digest, ce qui crée une dérive pour ArgoCD si le dépôt référence un tag
  • Périmètre : la politique ne vérifie que les images ciblées par matchImageReferences, complétez-la par une règle de registres autorisés
  • Registre privé : fournissez à Kyverno un secret de pull dans son namespace (credentials.secrets)
  • Disponibilité : si Kyverno est indisponible, l'admission échoue en mode fermé, d'où plusieurs réplicas en production

Secrets


base64 n'est pas un chiffrement

kubectl get secret securepay-stripe -n securepay \
  -o jsonpath='{.data.STRIPE_SECRET_KEY}' | base64 -d
  • Un Secret Kubernetes est seulement encodé : quiconque lit le YAML lit la valeur
  • Committé dans Git, il est exposé à tous les lecteurs du dépôt et à tout son historique
  • Stocké en clair dans etcd par défaut : k3s propose --secrets-encryption pour le chiffrement au repos
  • Avec k3d : --k3s-arg "--secrets-encryption@server:*" à la création du cluster
  • Le RBAC sur secrets reste la première barrière

Sealed Secrets ou SOPS

Sealed Secrets SOPS (age)
Principe Chiffré avec la clé publique du contrôleur Valeurs chiffrées, clés YAML en clair
Déchiffrement Par le contrôleur dans le cluster Par l'outil de déploiement (plugin ArgoCD)
Intégration ArgoCD Native : c'est une ressource Kubernetes Plugin (KSOPS, helm-secrets)
Portée Liée au namespace et au nom Indépendante de Kubernetes
Point de vigilance Sauvegarder la clé privée du contrôleur Protéger et distribuer la clé age
kubectl create secret generic securepay-stripe -n securepay \
  --from-literal=STRIPE_SECRET_KEY='<clé de test rotée>' --dry-run=client -o yaml \
  | kubeseal --format yaml > charts/securepay-api/templates/sealedsecret-stripe.yaml

Auditer et prouver


kube-bench : le CIS Kubernetes Benchmark

# Profils k3s disponibles dans kube-bench : k3s-cis-1.7 à k3s-cis-1.11
kubectl apply -f kube-bench-k3s-job.yaml
kubectl logs -n kube-bench job/kube-bench > kube-bench-$(date +%F_%H%M).txt
grep -E '^\[(FAIL|WARN)\]' kube-bench-*.txt | head
  • Lancé en Job sur le nœud server, avec hostPID et les répertoires de k3s montés en lecture seule
  • Choisissez le profil k3s le plus proche de votre version de Kubernetes et notez l'écart
  • Sur k3d, k3s tourne dans un conteneur : certains contrôles ne sont pas évaluables, documentez-les

Kubescape et Trivy

# Frameworks NSA et MITRE sur le namespace applicatif
kubescape scan framework nsa,mitre --include-namespaces securepay \
  --format json --output kubescape-$(date +%F_%H%M).json

# Conformité Pod Security Standards et CIS avec Trivy
trivy k8s --compliance k8s-pss-restricted-0.1 --report summary
trivy k8s --compliance k8s-cis-1.23 --report summary
  • Kubescape attribue un score par framework et pointe les ressources fautives
  • trivy k8s --compliance est encore marqué expérimental : croisez ses résultats avec kube-bench

Matrice de conformité

Exigence PSSI Contrôle Outil Preuve Statut
Aucun conteneur root PSA restricted + Kyverno API server, Kyverno Refus d'un pod root Conforme
Images signées par la CI uniquement ImageValidatingPolicy Kyverno, Cosign Refus d'une image non signée Conforme
Cloisonnement réseau NetworkPolicies en refus par défaut curl depuis un namespace tiers Timeout capturé Conforme
Aucun secret en clair dans Git Sealed Secrets Gitleaks, revue Rapport de scan Conforme
Conformité CIS du cluster Guide k3s kube-bench Rapports avant/après Partiel, écarts justifiés

Et au runtime ?

L'admission contrôle ce qui entre, pas ce qui se passe ensuite

  • Falco détecte un shell dans un conteneur, une lecture de /etc/shadow, un binaire inattendu
  • Tetragon et KubeArmor peuvent aussi bloquer au niveau du noyau (eBPF, LSM)
  • KubeHound cartographie les chemins d'attaque possibles entre identités et ressources

Comparatif : Falco, Tetragon, KubeArmor, KubeHound


À retenir

  • Namespace applicatif en PSA restricted, securityContext complet, pas de token monté inutilement
  • RBAC : une identité par composant, droits vérifiés avec kubectl auth can-i --list
  • NetworkPolicies : refus par défaut, puis DNS, ingress et scrape explicitement rouverts
  • Kyverno CEL (ValidatingPolicy, ImageValidatingPolicy) : Audit puis Deny, testé en CI
  • Seules les images signées par la pipeline, référencées par digest, sont admises
  • Secrets chiffrés avant Git, audits kube-bench et Kubescape avant/après dans la matrice de conformité

Pour aller plus loin

  • Pod Security Standards : https://kubernetes.io/docs/concepts/security/pod-security-standards/
  • Kyverno, migration vers CEL : https://kyverno.io/docs/guides/migration-to-cel/
  • Durcissement k3s : https://docs.k3s.io/security/hardening-guide
  • OWASP Kubernetes Top 10 : https://owasp.org/www-project-kubernetes-top-ten/