Aller au contenu

Durcir un cluster Kubernetes avec PSA, NetworkPolicies et Kyverno

Objectifs pédagogiques

À la fin de ce TP, vous devez être capables de :

  • mesurer la conformité initiale d'un cluster k3s avec kube-bench et Kubescape
  • imposer le standard restricted au namespace applicatif et adapter le chart en conséquence
  • vérifier et réduire les droits RBAC de l'application
  • cloisonner le réseau du namespace par défaut, puis rouvrir uniquement les flux nécessaires
  • traduire des exigences de la PSSI en politiques Kyverno CEL, d'abord en Audit puis en Deny
  • n'admettre que les images signées par votre pipeline
  • tester les politiques dans la CI avant qu'elles n'atteignent le cluster
  • sortir les secrets de Git en clair et consolider une matrice de conformité

Prérequis

  • Le cluster k3d securepay (1 server + 2 agents) sur la VM securepay-prod, accessible avec kubectl depuis votre poste
  • ArgoCD installé et l'API SecurePay déployée depuis charts/securepay-api du dépôt securepay-platform
  • Une image de l'API signée en keyless par la pipeline securepay-api (Cosign) et référencée par digest dans les valeurs du chart
  • Sur votre poste : kubectl, helm, Docker, et les CLI kubeseal, kubescape et trivy (versions épinglées et vérifiées)

GitOps d'abord

Toute modification durable passe par une Merge Request sur securepay-platform, synchronisée par ArgoCD. kubectl apply n'est utilisé que pour les audits ponctuels et les tests de refus. Une modification faite à la main serait de toute façon annulée par la réconciliation d'ArgoCD.

Contexte

L'audit PSSI de SecurePay a relevé des conteneurs exécutés en root, un réseau interne entièrement ouvert, aucune politique d'admission et une clé d'API en clair dans les manifests. Vous devez durcir le cluster, prouver chaque refus et consolider le tout dans une matrice de conformité.

Toutes les preuves vont dans docs/preuves/, nommées AAAA-MM-JJ_HHMM_<scenario>.<ext>.

Atelier 1 : Audit initial

Travail demandé

  1. Lancez kube-bench avec un profil k3s. Créez audit/kube-bench-k3s-job.yaml :
apiVersion: v1
kind: Namespace
metadata:
  name: kube-bench
  labels:
    pod-security.kubernetes.io/enforce: privileged
---
apiVersion: batch/v1
kind: Job
metadata:
  name: kube-bench
  namespace: kube-bench
spec:
  backoffLimit: 0
  template:
    spec:
      hostPID: true
      restartPolicy: Never
      nodeSelector:
        node-role.kubernetes.io/control-plane: "true"
      containers:
        - name: kube-bench
          image: docker.io/aquasec/kube-bench:<version>
          command: ["kube-bench", "run", "--benchmark", "k3s-cis-1.11"]
          volumeMounts:
            - { name: var-lib-rancher, mountPath: /var/lib/rancher, readOnly: true }
            - { name: etc-rancher, mountPath: /etc/rancher, readOnly: true }
            - { name: var-lib-kubelet, mountPath: /var/lib/kubelet, readOnly: true }
            - { name: var-lib-cni, mountPath: /var/lib/cni, readOnly: true }
      volumes:
        - { name: var-lib-rancher, hostPath: { path: /var/lib/rancher } }
        - { name: etc-rancher, hostPath: { path: /etc/rancher } }
        - { name: var-lib-kubelet, hostPath: { path: /var/lib/kubelet } }
        - { name: var-lib-cni, hostPath: { path: /var/lib/cni } }
kubectl apply -f audit/kube-bench-k3s-job.yaml
kubectl wait -n kube-bench --for=condition=complete job/kube-bench --timeout=180s
kubectl logs -n kube-bench job/kube-bench > "docs/preuves/$(date +%F_%H%M)_kube-bench-avant.txt"
kubectl delete namespace kube-bench

Choix du profil et limites sur k3d

kube-bench fournit des profils k3s-cis-1.7 à k3s-cis-1.11. Prenez le plus proche de votre version de Kubernetes (kubectl version) et notez l'écart dans votre rapport. Sur k3d, k3s s'exécute dans un conteneur, sans systemd, et la base de données par défaut est SQLite : les contrôles etcd et certains contrôles d'arguments ne sont pas évaluables. Documentez-les plutôt que de les ignorer.

  1. Lancez Kubescape et Trivy :
kubescape scan framework nsa,mitre --include-namespaces securepay \
  --format json --output "docs/preuves/$(date +%F_%H%M)_kubescape-avant.json"
trivy k8s --compliance k8s-pss-restricted-0.1 --report summary \
  | tee "docs/preuves/$(date +%F_%H%M)_trivy-pss-avant.txt"

Ce que vous devez observer

  • des FAIL et WARN kube-bench sur la configuration de k3s par défaut
  • des contrôles Kubescape en échec sur le pod de l'API : exécution en root, capabilities, absence de NetworkPolicy, token monté

Critère de réussite

Les trois rapports initiaux sont versionnés et les cinq écarts les plus critiques sont listés dans docs/conformite.md.

Atelier 2 : Pod Security Admission et chart durci

Travail demandé

  1. Déclarez le namespace dans le dépôt GitOps, d'abord en mode avertissement seulement :
apiVersion: v1
kind: Namespace
metadata:
  name: securepay
  labels:
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted
  1. Synchronisez, puis simulez le passage en enforce pour relever les avertissements sur les pods existants de l'API :
kubectl label namespace securepay pod-security.kubernetes.io/enforce=restricted --dry-run=server
  1. Durcissez le chart charts/securepay-api :

    • securityContext de pod : runAsNonRoot: true, runAsUser et runAsGroup non nuls, seccompProfile.type: RuntimeDefault
    • securityContext de conteneur : allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities.drop: ["ALL"]
    • volume emptyDir monté sur /tmp
    • automountServiceAccountToken: false et ServiceAccount dédié securepay-api
  2. Quand la commande de l'étape 2 ne renvoie plus d'avertissement, ajoutez au namespace :

    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
  1. Prouvez le refus d'un pod root :
kubectl run root-test -n securepay --image=nginx:1.27 2>&1 \
  | tee "docs/preuves/$(date +%F_%H%M)_psa-refus-root.txt"

Ce que vous devez observer

Error from server (Forbidden): pods "root-test" is forbidden: violates PodSecurity "restricted:latest":
allowPrivilegeEscalation != false (...), unrestricted capabilities (...), runAsNonRoot != true (...),
seccompProfile (...)
  • l'API redémarre sans erreur avec son système de fichiers en lecture seule
  • appliquer enforce avant d'avoir corrigé le chart aurait laissé la Deployment acceptée mais ses pods refusés (FailedCreate sur le ReplicaSet)

Critère de réussite

Le namespace est en restricted (enforce), l'API tourne, le refus du pod root est capturé.

Atelier 3 : RBAC au moindre privilège

Travail demandé

  1. Vérifiez les droits du ServiceAccount de l'API :
kubectl auth can-i --list -n securepay \
  --as=system:serviceaccount:securepay:securepay-api
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
  1. Listez les identités qui disposent de cluster-admin et justifiez chacune :
kubectl get clusterrolebindings -o json \
  | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name + " -> " + ([.subjects[]? | .kind + "/" + .name] | join(", "))'
  1. Vérifiez qu'aucun pod du namespace n'utilise le ServiceAccount default.

Critère de réussite

L'API ne dispose d'aucun droit sur l'API Kubernetes (réponses no) et la liste des détenteurs de cluster-admin est justifiée dans docs/conformite.md.

Atelier 4 : NetworkPolicies

Travail demandé

  1. Ajoutez au chart (ou à un dossier du dépôt GitOps) les trois politiques du support de cours : default-deny-all, allow-dns-egress et allow-traefik-and-prometheus. Vérifiez les labels réels de Traefik et de Prometheus avant d'écrire les sélecteurs :
kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik --show-labels
kubectl get pods -n monitoring --show-labels | grep prometheus
  1. Prouvez l'étanchéité depuis un namespace non autorisé :
kubectl get svc -n securepay
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
  1. Prouvez que le chemin nominal fonctionne toujours, via Traefik depuis votre poste :
curl -sS http://<IP de la VM>/health

Ce que vous devez observer

  • un timeout depuis le namespace default, alors que la même requête aboutissait avant l'atelier
  • une réponse 200 via Traefik
  • si la stack d'observabilité est déjà installée, la cible de scrape de l'API reste UP dans Prometheus

Critère de réussite

Les deux tests (refus et chemin nominal) sont capturés, et chaque ouverture de flux est justifiée dans la Merge Request.

Atelier 5 : Kyverno, de l'Audit au Deny

Travail demandé

  1. Déployez Kyverno par ArgoCD :
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: kyverno
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
spec:
  project: default
  source:
    repoURL: https://kyverno.github.io/kyverno/
    chart: kyverno
    targetRevision: <version du chart>
  destination:
    server: https://kubernetes.default.svc
    namespace: kyverno
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true, ServerSideApply=true]

ServerSideApply

Les CRD de Kyverno sont trop volumineuses pour l'annotation last-applied-configuration du mode client : ServerSideApply=true est nécessaire. Déclarez ensuite une seconde Application pour le dossier policies/, synchronisée après Kyverno.

  1. Créez policies/securepay-pod-baseline.yaml, d'abord avec validationActions: [Audit] :
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: securepay-pod-baseline
spec:
  validationActions: [Audit]
  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: "Registre non autorisé : seules les images de registry.gitlab.com/<groupe>/ sont admises"
      expression: "variables.containers.all(c, c.image.startsWith('registry.gitlab.com/<groupe>/'))"
    - 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')))
    - message: "Exécution en root ou en mode privilégié interdite"
      expression: >-
        variables.containers.all(c,
          c.?securityContext.?runAsNonRoot.orValue(object.spec.?securityContext.?runAsNonRoot.orValue(false)) == true &&
          c.?securityContext.?privileged.orValue(false) == false)
    - message: "requests CPU et mémoire et limite mémoire obligatoires"
      expression: >-
        object.spec.containers.all(c,
          'cpu' in c.?resources.?requests.orValue({}) &&
          'memory' in c.?resources.?requests.orValue({}) &&
          'memory' in c.?resources.?limits.orValue({}))
    - message: "readinessProbe et livenessProbe obligatoires"
      expression: "object.spec.containers.all(c, has(c.readinessProbe) && has(c.livenessProbe))"
  1. Consultez les rapports produits en mode Audit et corrigez le chart jusqu'à ce que l'API ne présente plus d'échec :
kubectl get policyreports -n securepay
  1. Passez la politique en validationActions: [Deny] par Merge Request.

  2. Prouvez les refus avec des manifests conformes à PSA (sinon c'est PSA qui refuse, pas Kyverno). Exemple tests/admission/pod-latest.yaml :

apiVersion: v1
kind: Pod
metadata:
  name: pod-latest
  namespace: securepay
spec:
  securityContext: { runAsNonRoot: true, runAsUser: 1001, seccompProfile: { type: RuntimeDefault } }
  containers:
    - name: api
      image: registry.gitlab.com/<groupe>/securepay-api:latest
      securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } }
      resources: { requests: { cpu: 50m, memory: 64Mi }, limits: { memory: 64Mi } }
      readinessProbe: { httpGet: { path: /ready, port: 3000 } }
      livenessProbe: { httpGet: { path: /health, port: 3000 } }

Déclinez-le en pod-registre-externe.yaml (image d'un autre registre) et pod-sans-probes.yaml, puis :

for f in tests/admission/*.yaml; do kubectl apply -f "$f"; done 2>&1 \
  | tee "docs/preuves/$(date +%F_%H%M)_kyverno-refus.txt"

Ce que vous devez observer

Error from server: error when creating "tests/admission/pod-latest.yaml": admission webhook
"vpol.validate.kyverno.svc-fail" denied the request: Policy securepay-pod-baseline failed:
Tag latest interdit : référencez une version ou un digest
  • en mode Audit, les mêmes pods étaient admis mais apparaissaient en FAIL dans les PolicyReports
  • une Deployment non conforme est refusée dès sa création : les règles écrites pour les Pods sont appliquées aux contrôleurs (autogen)

Critère de réussite

La politique est en Deny, l'API est conforme et trois refus distincts sont capturés.

Atelier 6 : N'admettre que les images signées

Travail demandé

  1. Donnez à Kyverno l'accès en lecture au registre : créez un deploy token GitLab read_registry, puis un secret docker-registry nommé gitlab-registry dans le namespace kyverno, chiffré avec Sealed Secrets (atelier 8).

  2. Créez policies/verify-securepay-signature.yaml avec l'ImageValidatingPolicy du support de cours, en ajoutant :

  credentials:
    secrets: ["gitlab-registry"]

Remplacez <groupe> par le chemin de votre groupe GitLab. Vérifiez l'identité exacte avec Cosign avant d'écrire la politique :

cosign verify registry.gitlab.com/<groupe>/securepay-api@<digest> \
  --certificate-identity "https://gitlab.com/<groupe>/securepay-api//.gitlab-ci.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://gitlab.com"
  1. Construisez et poussez une image non signée depuis votre poste, puis tentez de la déployer par son digest avec un manifest conforme à toutes les autres règles :
docker build -t registry.gitlab.com/<groupe>/securepay-api:non-signee .
docker push registry.gitlab.com/<groupe>/securepay-api:non-signee   # relevez le digest affiché

Ce que vous devez observer

  • l'image signée par la pipeline main est admise
  • l'image non signée est refusée avec le message de la politique
  • une image référencée par tag sans digest est refusée elle aussi : promouvez toujours par digest

Critère de réussite

Le refus de l'image non signée et l'admission de l'image signée sont capturés.

Atelier 7 : Tester les politiques dans la CI

Travail demandé

  1. Ajoutez les tests dans tests/policies/, hors du dossier policies/ synchronisé par ArgoCD, par exemple tests/policies/kyverno-test.yaml :
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
  name: securepay-pod-baseline
policies:
  - ../../policies/securepay-pod-baseline.yaml
resources:
  - resources.yaml
results:
  - policy: securepay-pod-baseline
    isValidatingPolicy: true
    kind: Deployment
    resources: [securepay-api]
    result: pass
  - policy: securepay-pod-baseline
    isValidatingPolicy: true
    kind: Pod
    resources: [pod-latest, pod-registre-externe, pod-sans-probes]
    result: fail

tests/policies/resources.yaml contient une Deployment conforme et les trois pods fautifs de l'atelier 5.

Un faux vert silencieux

Si des ressources de test se trouvent dans le dossier passé à kyverno apply, le CLI charge zéro politique et sort avec le code 0. Dans le même dossier, ArgoCD tenterait aussi de déployer ces pods de test. Gardez policies/ pour les seules politiques.

  1. Ajoutez le job policies:test du support de cours au .gitlab-ci.yml de securepay-platform : il exécute kyverno test tests/policies/ puis kyverno apply de la ValidatingPolicy sur le chart rendu par helm template. La vérification de signature reste à l'admission et dans la pipeline securepay-api (cosign verify) : en CI, elle demanderait un accès au registre.

Pourquoi installer le binaire

L'image officielle du CLI Kyverno ne contient pas de shell : elle ne peut pas exécuter le script: d'un job GitLab. Le job télécharge donc le binaire de la release épinglée et vérifie sa somme de contrôle.

  1. Ouvrez une Merge Request qui retire la livenessProbe du chart et vérifiez que le job échoue.

Critère de réussite

kyverno test passe, et une Merge Request non conforme est bloquée en CI avant toute synchronisation.

Atelier 8 : Secrets hors de Git en clair

Travail demandé

  1. Montrez qu'un Secret Kubernetes n'est pas protégé :
kubectl get secret -n securepay -o yaml | head -20
kubectl get secret <nom> -n securepay -o jsonpath='{.data.STRIPE_SECRET_KEY}' | base64 -d
  1. Révoquez la clé de test exposée dans l'historique et générez-en une nouvelle (factice pour le TP).

  2. Déployez le contrôleur Sealed Secrets par ArgoCD (chart sealed-secrets du dépôt https://bitnami.github.io/sealed-secrets, dans kube-system), puis chiffrez le secret :

kubectl create secret generic securepay-stripe -n securepay \
  --from-literal=STRIPE_SECRET_KEY='<nouvelle clé de test>' --dry-run=client -o yaml \
  | kubeseal --controller-namespace kube-system --controller-name <nom du service du contrôleur> \
      --format yaml > charts/securepay-api/templates/sealedsecret-stripe.yaml
  1. Supprimez du dépôt tout Secret en clair et vérifiez avec Gitleaks qu'aucune valeur ne subsiste dans les fichiers suivis.

  2. Sauvegardez la clé privée du contrôleur hors du cluster, dans un coffre : sans elle, une reconstruction du cluster rend vos SealedSecrets indéchiffrables.

  3. Optionnel : recréez le cluster avec le chiffrement des secrets au repos (k3d cluster create ... --k3s-arg "--secrets-encryption@server:*") et vérifiez avec docker exec k3d-securepay-server-0 k3s secrets-encrypt status.

Alternative SOPS

SOPS avec une clé age chiffre les valeurs tout en laissant les clés YAML lisibles. Il demande un plugin côté ArgoCD (KSOPS ou helm-secrets) : documentez votre choix dans un ADR.

Critère de réussite

Le dépôt ne contient plus aucun secret en clair, l'API lit sa clé depuis le Secret déchiffré par le contrôleur, et la sauvegarde de la clé du contrôleur est documentée.

Atelier 9 : Audit final et matrice de conformité

Travail demandé

  1. Rejouez kube-bench, Kubescape et Trivy avec les mêmes commandes qu'à l'atelier 1 et rangez les rapports -apres.

  2. Complétez docs/conformite.md :

Exigence PSSI Contrôle Outil Preuve Statut
Aucun conteneur root PSA restricted + Kyverno API server, Kyverno psa-refus-root.txt
Images de l'entreprise uniquement Règle de registres autorisés Kyverno kyverno-refus.txt
Images signées par la CI uniquement ImageValidatingPolicy Kyverno, Cosign
Cloisonnement réseau NetworkPolicies en refus par défaut curl
Droits minimaux RBAC dédié, pas de token monté kubectl auth can-i
Aucun secret en clair dans Git Sealed Secrets Gitleaks
Conformité CIS du cluster Guide de durcissement k3s kube-bench Rapports avant/après
  1. Pour chaque FAIL kube-bench restant : correction, dérogation justifiée, ou contrôle non évaluable sur k3d.

Critère de réussite

Chaque ligne de la matrice pointe vers une preuve versionnée, et aucun écart n'est laissé sans statut.

Questions de synthèse

  • Pourquoi conserver Pod Security Admission alors que Kyverno peut vérifier les mêmes règles
  • Pourquoi un manifest de test doit-il être conforme à PSA pour prouver qu'une règle Kyverno fonctionne
  • Quel risque introduit le mode d'échec fermé du webhook de Kyverno, et comment le maîtriser
  • En quoi le droit create pods dans un namespace donne-t-il accès à ses secrets
  • Pourquoi une politique de signature doit-elle être complétée par une politique de registres autorisés
  • Que perdez-vous si la clé privée du contrôleur Sealed Secrets disparaît