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
restrictedau 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 VMsecurepay-prod, accessible aveckubectldepuis votre poste - ArgoCD installé et l'API SecurePay déployée depuis
charts/securepay-apidu dépôtsecurepay-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 CLIkubeseal,kubescapeettrivy(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é¶
- 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.
- 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
FAILetWARNkube-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é¶
- 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
- Synchronisez, puis simulez le passage en
enforcepour relever les avertissements sur les pods existants de l'API :
-
Durcissez le chart
charts/securepay-api:securityContextde pod :runAsNonRoot: true,runAsUseretrunAsGroupnon nuls,seccompProfile.type: RuntimeDefaultsecurityContextde conteneur :allowPrivilegeEscalation: false,readOnlyRootFilesystem: true,capabilities.drop: ["ALL"]- volume
emptyDirmonté sur/tmp automountServiceAccountToken: falseet ServiceAccount dédiésecurepay-api
-
Quand la commande de l'étape 2 ne renvoie plus d'avertissement, ajoutez au namespace :
- 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
enforceavant d'avoir corrigé le chart aurait laissé la Deployment acceptée mais ses pods refusés (FailedCreatesur 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é¶
- 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
- Listez les identités qui disposent de
cluster-adminet justifiez chacune :
kubectl get clusterrolebindings -o json \
| jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name + " -> " + ([.subjects[]? | .kind + "/" + .name] | join(", "))'
- 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é¶
- Ajoutez au chart (ou à un dossier du dépôt GitOps) les trois politiques du support de cours :
default-deny-all,allow-dns-egressetallow-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
- 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
- Prouvez que le chemin nominal fonctionne toujours, via Traefik depuis votre poste :
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
200via Traefik - si la stack d'observabilité est déjà installée, la cible de scrape de l'API reste
UPdans 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é¶
- 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.
- Créez
policies/securepay-pod-baseline.yaml, d'abord avecvalidationActions: [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))"
- Consultez les rapports produits en mode Audit et corrigez le chart jusqu'à ce que l'API ne présente plus d'échec :
-
Passez la politique en
validationActions: [Deny]par Merge Request. -
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
FAILdans 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é¶
-
Donnez à Kyverno l'accès en lecture au registre : créez un deploy token GitLab
read_registry, puis un secretdocker-registrynommégitlab-registrydans le namespacekyverno, chiffré avec Sealed Secrets (atelier 8). -
Créez
policies/verify-securepay-signature.yamlavec l'ImageValidatingPolicydu support de cours, en ajoutant :
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"
- 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
mainest 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é¶
- Ajoutez les tests dans
tests/policies/, hors du dossierpolicies/synchronisé par ArgoCD, par exempletests/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.
- Ajoutez le job
policies:testdu support de cours au.gitlab-ci.ymldesecurepay-platform: il exécutekyverno test tests/policies/puiskyverno applyde laValidatingPolicysur le chart rendu parhelm template. La vérification de signature reste à l'admission et dans la pipelinesecurepay-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.
- Ouvrez une Merge Request qui retire la
livenessProbedu 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é¶
- 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
-
Révoquez la clé de test exposée dans l'historique et générez-en une nouvelle (factice pour le TP).
-
Déployez le contrôleur Sealed Secrets par ArgoCD (chart
sealed-secretsdu dépôthttps://bitnami.github.io/sealed-secrets, danskube-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
-
Supprimez du dépôt tout Secret en clair et vérifiez avec Gitleaks qu'aucune valeur ne subsiste dans les fichiers suivis.
-
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.
-
Optionnel : recréez le cluster avec le chiffrement des secrets au repos (
k3d cluster create ... --k3s-arg "--secrets-encryption@server:*") et vérifiez avecdocker 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é¶
-
Rejouez kube-bench, Kubescape et Trivy avec les mêmes commandes qu'à l'atelier 1 et rangez les rapports
-apres. -
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 |
- Pour chaque
FAILkube-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 podsdans 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