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
restrictedaux 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 RoleetRoleBindingdans le namespace plutôt queClusterRoleglobale- Aucun
cluster-adminhors 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,watchsursecrets: lecture de tous les secrets du namespacecreatesurpods: un pod peut monter n'importe quel secret ou ServiceAccount du namespaceescalate,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
namespaceSelectoret unpodSelectordans le même élément defromforment un ET - Deux éléments distincts de la liste
fromforment 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
latestet 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¶
ValidatingPolicyetImageValidatingPolicy(policies.kyverno.io/v1) sont les types recommandés- Leurs expressions utilisent CEL, le langage des ValidatingAdmissionPolicies natives de Kubernetes
ClusterPolicyetPolicy(kyverno.io/v1) sont dépréciées depuis Kyverno 1.19, leur suppression est annoncée- Le guide officiel de migration associe
validateàValidatingPolicyetverifyImagesà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¶
- 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 applysort 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.ymlfait 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-encryptionpour le chiffrement au repos - Avec k3d :
--k3s-arg "--secrets-encryption@server:*"à la création du cluster - Le RBAC sur
secretsreste 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
hostPIDet 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 --complianceest 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,securityContextcomplet, 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/