Sécurité Kubernetes#
Kubernetes est un système distribué complexe avec de nombreuses surfaces d’attaque. Une configuration par défaut est rarement sécurisée. Ce chapitre couvre les mécanismes de sécurité essentiels : RBAC, Pod Security, NetworkPolicy, gestion des secrets et sécurité des images.
Le modèle de menaces Kubernetes#
Avant de parler de solutions, il faut comprendre les surfaces d’attaque de Kubernetes.
RBAC : contrôle d’accès basé sur les rôles#
RBAC (Role-Based Access Control) est le système d’autorisation de Kubernetes. Il répond à la question : qui peut faire quoi sur quels objets ?
Les quatre ressources RBAC#
Ressource |
Portée |
Description |
|---|---|---|
|
Namespace |
Permissions dans un namespace spécifique |
|
Cluster entier |
Permissions sur toutes les ressources (ou ressources non-namespacées) |
|
Namespace |
Associe un Role (ou ClusterRole) à un sujet |
|
Cluster entier |
Associe un ClusterRole à un sujet pour tout le cluster |
ServiceAccount : l’identité des Pods#
# serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: mon-application
namespace: production
automountServiceAccountToken: false # Désactiver l'injection automatique du token
# role-minimal.yaml — Principe du moindre privilège
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: lecteur-pods
namespace: production
rules:
# Chaque règle spécifie : groupes d'API, ressources, verbes autorisés
- apiGroups: [""] # "" = Core API group
resources: ["pods"]
verbs: ["get", "list", "watch"] # Lecture seule
# NE PAS mettre "create", "delete", "patch" si non nécessaire !
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
# Pas de droit sur les secrets, configmaps, services — non nécessaire
# rolebinding.yaml — Attacher le Role au ServiceAccount
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mon-app-lecteur
namespace: production
subjects:
- kind: ServiceAccount
name: mon-application
namespace: production
roleRef:
kind: Role
name: lecteur-pods
apiGroup: rbac.authorization.k8s.io
ClusterRole vs Role — quand utiliser lequel ?
Role : pour les permissions limitées à un namespace (application, CI/CD limité à un namespace)
ClusterRole : pour les ressources non-namespacées (Nodes, PersistentVolumes, Namespaces) ou pour les outils qui doivent accéder à tout le cluster (monitoring, backup)
Un ClusterRole peut être utilisé dans un RoleBinding pour limiter sa portée à un namespace — c’est souvent la meilleure pratique : définir le ClusterRole une fois, le lier à des namespaces spécifiques.
Pod Security : sécuriser les conteneurs#
SecurityContext#
Le securityContext définit les paramètres de sécurité d’un Pod ou d’un conteneur.
# pod-securise.yaml
apiVersion: v1
kind: Pod
metadata:
name: pod-securise
spec:
# SecurityContext au niveau du Pod (s'applique à tous les conteneurs)
securityContext:
runAsNonRoot: true # Refuser si l'image utilise root
runAsUser: 1000 # UID 1000
runAsGroup: 3000 # GID 3000
fsGroup: 2000 # GID pour les volumes montés
seccompProfile:
type: RuntimeDefault # Profil seccomp par défaut (filtre les syscalls)
containers:
- name: app
image: mon-app:1.0
# SecurityContext au niveau du conteneur (surcharge le Pod)
securityContext:
allowPrivilegeEscalation: false # Empêche sudo / setuid
readOnlyRootFilesystem: true # FS en lecture seule
capabilities:
drop: ["ALL"] # Retirer TOUTES les capabilities Linux
add: ["NET_BIND_SERVICE"] # Ré-ajouter seulement ce qui est nécessaire
volumeMounts:
# Si readOnlyRootFilesystem: true, les répertoires avec écriture nécessaire
# doivent être montés explicitement
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
PodSecurityAdmission (PSA)#
Depuis Kubernetes 1.25, PodSecurityAdmission est le mécanisme intégré pour appliquer des profils de sécurité au niveau du namespace.
# Appliquer un profil de sécurité à un namespace entier
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
# Mode enforce : rejette les Pods non conformes
pod-security.kubernetes.io/enforce: restricted
# Mode warn : avertissement mais le Pod est quand même créé
pod-security.kubernetes.io/warn: restricted
# Mode audit : enregistre dans les logs d'audit
pod-security.kubernetes.io/audit: restricted
Niveaux de sécurité PSA :
privileged: aucune restriction (réservé aux namespaces système)baseline: restrictions minimales (empêche les escalades de privilèges connues)restricted: strictement sécurisé (bonne pratique pour les applications de production)
NetworkPolicy : isolation réseau#
Par défaut dans Kubernetes, tous les Pods peuvent communiquer avec tous les autres Pods dans le cluster. C’est pratique pour le développement, mais dangereux en production.
# networkpolicy-deny-all.yaml — Bloquer tout le trafic par défaut
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all
namespace: production
spec:
podSelector: {} # S'applique à TOUS les Pods du namespace
policyTypes:
- Ingress
- Egress
# Pas de règles → tout est bloqué
# networkpolicy-api.yaml — Autoriser seulement le trafic nécessaire
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-network-policy
namespace: production
spec:
podSelector:
matchLabels:
app: api # S'applique aux Pods de l'API
policyTypes:
- Ingress
- Egress
ingress:
# Autoriser le trafic entrant depuis l'Ingress Controller (namespace ingress-nginx)
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- port: 8080
# Autoriser depuis d'autres Pods de l'API (réplication)
- from:
- podSelector:
matchLabels:
app: api
ports:
- port: 8080
egress:
# Autoriser vers la base de données (même namespace)
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- port: 5432
# Autoriser les requêtes DNS (OBLIGATOIRE si on bloque tout)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
Gestion des secrets : au-delà du base64#
La fausse sécurité du base64#
# Un Secret Kubernetes est par défaut stocké en base64 — ce N'EST PAS du chiffrement !
kubectl get secret mon-secret -o yaml
# data:
# password: dGVzdDEyMzQ=
# Décoder est trivial :
echo "dGVzdDEyMzQ=" | base64 -d
# test1234
Base64 ≠ Chiffrement
Le base64 est un encodage, pas un chiffrement. N’importe qui ayant accès à etcd ou au fichier YAML peut lire vos secrets. Pour une vraie sécurité, il faut :
Chiffrement de etcd au repos (
--encryption-provider-config)RBAC strict sur les Secrets (peu de ServiceAccounts y ont accès)
Outils de gestion de secrets externes (Vault, External Secrets Operator)
HashiCorp Vault + External Secrets Operator#
# External Secrets Operator : synchronise les secrets d'un vault externe vers K8s
# Installation
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets -n external-secrets --create-namespace
# secretstore.yaml — Connexion à HashiCorp Vault
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.exemple.com:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "mon-application"
# externalsecret.yaml — Récupérer un secret depuis Vault
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: production
spec:
refreshInterval: 1h # Synchroniser toutes les heures
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: db-credentials-k8s # Nom du Secret Kubernetes créé
creationPolicy: Owner
data:
- secretKey: username # Clé dans le Secret K8s
remoteRef:
key: production/database # Chemin dans Vault
property: username # Propriété dans le secret Vault
- secretKey: password
remoteRef:
key: production/database
property: password
Sécurité des images#
Scanning avec Trivy#
# Scanner une image avec Trivy (outil gratuit, très complet)
trivy image nginx:latest
# Résultat :
# nginx:latest (debian 12.4)
# =============================
# Total: 23 (HIGH: 5, CRITICAL: 2)
#
# CVE-2024-XXXX CRITICAL curl 7.88.1 → mettre à jour vers 8.x
# Scanner dans un Dockerfile (avant de pousser)
trivy image --exit-code 1 --severity CRITICAL mon-app:latest
# --exit-code 1 : fait échouer le build si des CVE CRITICAL sont trouvées
Kyverno : admission controller de validation#
Kyverno est un admission controller qui valide, mute et génère des ressources Kubernetes selon des politiques déclaratives.
# Installer Kyverno
helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno -n kyverno --create-namespace
# kyverno-policy-nonroot.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-non-root
spec:
validationFailureAction: Enforce # Reject si non conforme
rules:
- name: check-runAsNonRoot
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["production", "staging"]
validate:
message: "Les Pods doivent s'exécuter en tant qu'utilisateur non-root"
pattern:
spec:
securityContext:
runAsNonRoot: "true"
Simulation Python : audit de conformité RBAC et vérification de manifestes#
from dataclasses import dataclass, field
from typing import List, Dict, Set, Optional
@dataclass
class VerificateurManifeste:
"""
Vérifie la conformité sécurité d'un manifeste Pod Kubernetes.
Simule un admission controller ou un outil comme Kyverno/OPA.
"""
def verifier(self, manifeste: dict) -> dict:
"""Analyse un manifeste et retourne les résultats."""
resultats = {"ok": [], "avertissements": [], "critiques": []}
spec = manifeste.get("spec", {})
containers = spec.get("containers", [])
pod_security = spec.get("securityContext", {})
# --- Vérifications au niveau du Pod ---
if pod_security.get("runAsNonRoot"):
resultats["ok"].append("Pod.securityContext.runAsNonRoot = true")
else:
resultats["critiques"].append(
"Pod.securityContext.runAsNonRoot manquant — le pod peut tourner en root")
if spec.get("automountServiceAccountToken") is False:
resultats["ok"].append("automountServiceAccountToken = false")
else:
resultats["avertissements"].append(
"automountServiceAccountToken non désactivé — le token SA est monté par défaut")
# --- Vérifications par conteneur ---
for container in containers:
nom = container.get("name", "?")
ctx = container.get("securityContext", {})
resources = container.get("resources", {})
# Capabilities
if ctx.get("capabilities", {}).get("drop") == ["ALL"]:
resultats["ok"].append(f"{nom}: capabilities.drop = ALL")
else:
resultats["avertissements"].append(
f"{nom}: capabilities.drop ALL non configuré")
# allowPrivilegeEscalation
if ctx.get("allowPrivilegeEscalation") is False:
resultats["ok"].append(f"{nom}: allowPrivilegeEscalation = false")
else:
resultats["critiques"].append(
f"{nom}: allowPrivilegeEscalation non désactivé → risque d'escalade")
# readOnlyRootFilesystem
if ctx.get("readOnlyRootFilesystem"):
resultats["ok"].append(f"{nom}: readOnlyRootFilesystem = true")
else:
resultats["avertissements"].append(
f"{nom}: readOnlyRootFilesystem = false — l'app peut modifier son FS")
# Resources
if resources.get("requests") and resources.get("limits"):
resultats["ok"].append(f"{nom}: resources requests et limits définis")
elif resources.get("requests"):
resultats["avertissements"].append(
f"{nom}: limits manquants — risque de consommation excessive")
else:
resultats["critiques"].append(
f"{nom}: aucun resources défini — BestEffort QoS, planification non garantie")
# Secrets dans les env vars
for env in container.get("env", []):
if "password" in env.get("name", "").lower() or \
"secret" in env.get("name", "").lower() or \
"key" in env.get("name", "").lower():
if "value" in env: # valeur en clair !
resultats["critiques"].append(
f"{nom}: secret '{env['name']}' en clair dans les env vars !")
elif "valueFrom" in env and "secretKeyRef" in env["valueFrom"]:
resultats["ok"].append(
f"{nom}: secret '{env['name']}' lu depuis un Secret K8s")
return resultats
def rapport(self, nom_manifeste: str, resultats: dict):
total = sum(len(v) for v in resultats.values())
score = len(resultats["ok"]) / total * 100 if total > 0 else 0
print(f"\n{'='*60}")
print(f"Audit de sécurité : {nom_manifeste}")
print(f"Score de conformité : {score:.0f}% ({len(resultats['ok'])}/{total} contrôles réussis)")
print(f"{'='*60}")
if resultats["critiques"]:
print(f"\n❌ CRITIQUE ({len(resultats['critiques'])}) :")
for r in resultats["critiques"]:
print(f" ✗ {r}")
if resultats["avertissements"]:
print(f"\n⚠ AVERTISSEMENTS ({len(resultats['avertissements'])}) :")
for r in resultats["avertissements"]:
print(f" ! {r}")
if resultats["ok"]:
print(f"\n✅ RÉUSSIS ({len(resultats['ok'])}) :")
for r in resultats["ok"]:
print(f" ✓ {r}")
verificateur = VerificateurManifeste()
# Manifeste non sécurisé
manifeste_mauvais = {
"spec": {
"containers": [{
"name": "webapp",
"image": "mon-app:latest",
"env": [
{"name": "DATABASE_PASSWORD", "value": "MonMotDePasse123"}, # DANGER !
{"name": "APP_ENV", "value": "production"},
],
"resources": {
"requests": {"cpu": "100m"}
# limits manquantes !
}
# securityContext manquant
}]
# pas de securityContext au niveau pod
}
}
# Manifeste sécurisé
manifeste_bon = {
"spec": {
"automountServiceAccountToken": False,
"securityContext": {
"runAsNonRoot": True,
"runAsUser": 1000,
"fsGroup": 2000,
},
"containers": [{
"name": "webapp",
"image": "mon-app:1.2.3",
"env": [
{
"name": "DATABASE_PASSWORD",
"valueFrom": {"secretKeyRef": {"name": "db-secret", "key": "password"}}
},
],
"resources": {
"requests": {"cpu": "100m", "memory": "128Mi"},
"limits": {"cpu": "500m", "memory": "256Mi"},
},
"securityContext": {
"allowPrivilegeEscalation": False,
"readOnlyRootFilesystem": True,
"capabilities": {"drop": ["ALL"]},
}
}]
}
}
res_mauvais = verificateur.verifier(manifeste_mauvais)
verificateur.rapport("pod-non-securise.yaml", res_mauvais)
res_bon = verificateur.verifier(manifeste_bon)
verificateur.rapport("pod-securise.yaml", res_bon)
============================================================
Audit de sécurité : pod-non-securise.yaml
Score de conformité : 0% (0/7 contrôles réussis)
============================================================
❌ CRITIQUE (3) :
✗ Pod.securityContext.runAsNonRoot manquant — le pod peut tourner en root
✗ webapp: allowPrivilegeEscalation non désactivé → risque d'escalade
✗ webapp: secret 'DATABASE_PASSWORD' en clair dans les env vars !
⚠ AVERTISSEMENTS (4) :
! automountServiceAccountToken non désactivé — le token SA est monté par défaut
! webapp: capabilities.drop ALL non configuré
! webapp: readOnlyRootFilesystem = false — l'app peut modifier son FS
! webapp: limits manquants — risque de consommation excessive
============================================================
Audit de sécurité : pod-securise.yaml
Score de conformité : 100% (7/7 contrôles réussis)
============================================================
✅ RÉUSSIS (7) :
✓ Pod.securityContext.runAsNonRoot = true
✓ automountServiceAccountToken = false
✓ webapp: capabilities.drop = ALL
✓ webapp: allowPrivilegeEscalation = false
✓ webapp: readOnlyRootFilesystem = true
✓ webapp: resources requests et limits définis
✓ webapp: secret 'DATABASE_PASSWORD' lu depuis un Secret K8s
Points clés à retenir#
RBAC est le mécanisme d’autorisation de Kubernetes : Role/ClusterRole définissent les permissions, RoleBinding/ClusterRoleBinding les associent à des sujets (users, ServiceAccounts)
Principe du moindre privilège : accorder uniquement les verbes et ressources strictement nécessaires — ne jamais utiliser
verbs: ["*"]en productionLe
securityContextpermet de configurerrunAsNonRoot,readOnlyRootFilesystem,capabilities.drop: ALLetallowPrivilegeEscalation: falseNetworkPolicy isole le trafic réseau entre Pods — commencer par un
deny-allpuis ouvrir sélectivementLe base64 des Secrets Kubernetes n’est pas du chiffrement — utiliser le chiffrement d’etcd au repos et/ou un gestionnaire de secrets externe (Vault, External Secrets Operator)
Kyverno ou OPA/Gatekeeper permettent d’appliquer des politiques de sécurité automatiquement à l’admission des Pods
Scanner régulièrement les images avec Trivy et intégrer ce scan dans la CI/CD