14. Kubernetes RBAC et sécurité de l’orchestration#
Kubernetes est devenu le standard de facto pour l’orchestration de conteneurs, mais sa richesse fonctionnelle introduit une surface d’attaque considérable. Ce chapitre se concentre sur la sécurité : contrôle d’accès basé sur les rôles (RBAC), politiques réseau, standards de sécurité des pods et audit logging.
Surface d’attaque Kubernetes#
API Server#
L’API Server est le point d’entrée unique de tout cluster Kubernetes. Sa compromission équivaut à un accès total au cluster.
Authentification : Kubernetes supporte plusieurs méthodes — certificats X.509, tokens de service, OIDC, webhooks. La méthode --anonymous-auth=true (désactivée par défaut depuis K8s 1.6) permet des requêtes non authentifiées.
TLS : toutes les communications vers l’API Server doivent être chiffrées. Les configurations exposant l’API Server en HTTP (port 8080, interface --insecure-bind-address) sont critiques.
etcd : chiffrement au repos#
etcd stocke l’état complet du cluster, y compris les Secrets Kubernetes. Sans chiffrement au repos, un accès au disque etcd expose tous les secrets :
# kube-apiserver — activer le chiffrement etcd
--encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml
# encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {}
Kubelet : port 10250#
Le kubelet expose une API sur le port 10250. Sans authentification, un attaquant peut exécuter des commandes dans n’importe quel pod du nœud :
# Attaque sans authentification (mauvaise configuration)
curl -sk https://node-ip:10250/run/default/mypod/mycontainer \
-d "cmd=id"
La configuration sécurisée impose --anonymous-auth=false et --authorization-mode=Webhook sur le kubelet.
Évasion de conteneur vers le nœud#
Depuis un pod compromis, un attaquant peut tenter d’atteindre le nœud hôte via :
Montage du filesystem hôte (
hostPath)Partage du namespace PID hôte (
hostPID: true)Accès au socket Docker/containerd du nœud
Exploitation de vulnérabilités du noyau
Règle des 4C de la sécurité cloud-native
La sécurité Kubernetes s’articule en couches concentriques : Cloud (infra), Cluster (API server, etcd), Container (runtime), Code (application). Une faille dans une couche externe peut contourner les contrôles des couches internes.
RBAC Kubernetes : principes et objets#
Modèle RBAC#
Kubernetes implémente un RBAC basé sur quatre objets :
Objet |
Scope |
Description |
|---|---|---|
|
Namespace |
Identité d’un pod |
|
Namespace |
Permissions sur des ressources namespaced |
|
Cluster |
Permissions sur des ressources cluster-wide |
|
Namespace |
Lie un sujet à un Role (ou ClusterRole) |
|
Cluster |
Lie un sujet à un ClusterRole globalement |
Définition d’un Role restrictif#
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-pods-binding
namespace: production
subjects:
- kind: ServiceAccount
name: monitoring-agent
namespace: production
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
Verbes dangereux#
Certains verbes Kubernetes accordent des permissions d’escalade de privilèges :
Verbe |
Ressource |
Risque |
|---|---|---|
|
|
Accès total — équivaut à cluster-admin |
|
|
Peut s’attribuer n’importe quel rôle |
|
|
Peut modifier un rôle pour s’accorder des droits |
|
|
Peut agir en tant qu’un autre sujet |
|
|
Exécution de commandes dans n’importe quel pod |
|
|
Peut lancer un pod |
Anti-patterns RBAC#
Anti-patterns RBAC courants
cluster-admingénéralisé : attribuercluster-adminà des opérateurs ou des applications de CI/CD. Un seul token compromis donne le contrôle total du cluster.ServiceAccount par défaut avec token automontage : tous les pods utilisent par défaut le ServiceAccount
defaultavec un token. Désactiver avecautomountServiceAccountToken: false.Wildcards dans les resources :
resources: ["*"]accorde l’accès à des ressources futures non anticipées.Bindings cross-namespace : utiliser un
ClusterRoleBindingquand unRoleBindingsuffit élargit inutilement le scope.
Network Policies : isolation L3/L4#
Principe des Network Policies#
Par défaut, Kubernetes autorise tout le trafic entre pods (modèle flat network). Les Network Policies permettent de définir des règles d’ingress et d’egress au niveau IP/port.
Prérequis : le CNI plugin doit supporter les Network Policies (Calico, Cilium, Weave Net, mais pas Flannel seul).
Default-deny : politique de base#
# Bloquer tout le trafic entrant dans le namespace production
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: production
spec:
podSelector: {} # Sélectionne tous les pods
policyTypes:
- Ingress
- Egress
# Autoriser seulement le frontend vers le backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
Pod Security Standards#
Les Pod Security Standards (PSS) remplacent les PodSecurityPolicies (dépréciées en 1.21, supprimées en 1.25). Trois niveaux sont définis :
Niveau |
Description |
Usage recommandé |
|---|---|---|
|
Aucune restriction |
Nœuds système, CNI plugins |
|
Prévient les escalades connues |
Applications générales |
|
Durcissement complet |
Applications sensibles |
Activation via labels de namespace#
# Appliquer le niveau Restricted avec mode enforce
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
securityContext hardened#
Le securityContext configure les paramètres de sécurité au niveau pod et conteneur :
apiVersion: v1
kind: Pod
metadata:
name: secure-pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: gcr.io/distroless/java21:nonroot
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
automountServiceAccountToken: false
Admission controllers de sécurité#
OPA/Gatekeeper#
Gatekeeper est un webhook d’admission Kubernetes qui évalue des politiques OPA (Rego) :
# ConstraintTemplate — définit le type de contrainte
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names:
kind: K8sRequiredLabels
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg}] {
not input.review.object.metadata.labels["app"]
msg := "Label 'app' obligatoire"
}
Kyverno#
Kyverno utilise des politiques YAML nativement Kubernetes, sans langage Rego :
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-privileged-containers
spec:
validationFailureAction: Enforce
rules:
- name: check-privileged
match:
any:
- resources:
kinds: ["Pod"]
validate:
message: "Les conteneurs privilégiés sont interdits"
pattern:
spec:
containers:
- =(securityContext):
=(privileged): "false"
- name: require-non-root
match:
any:
- resources:
kinds: ["Pod"]
validate:
message: "runAsNonRoot doit être true"
pattern:
spec:
securityContext:
runAsNonRoot: true
Audit logging Kubernetes#
Politique d’audit#
L’audit logging enregistre les requêtes vers l’API Server. Quatre niveaux sont disponibles :
Niveau |
Contenu enregistré |
|---|---|
|
Rien |
|
Métadonnées de la requête (qui, quoi, quand) |
|
Métadonnées + corps de la requête |
|
Métadonnées + corps requête + corps réponse |
# audit-policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
# Ne pas loguer les health checks
- level: None
users: ["system:kube-proxy"]
verbs: ["watch"]
resources:
- group: ""
resources: ["endpoints", "services"]
# Logger les secrets en RequestResponse
- level: RequestResponse
resources:
- group: ""
resources: ["secrets"]
# Logger les execs de pods
- level: RequestResponse
resources:
- group: ""
resources: ["pods/exec", "pods/attach"]
# Niveau par défaut
- level: Metadata
Visualisations#
Matrice RBAC Kubernetes#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
service_accounts = ["ci-pipeline\n(cluster-admin)", "monitoring\n(pod-reader)", "app-backend\n(minimal)", "ingress-ctrl\n(networking)", "db-operator\n(secret-rw)"]
ressources_verbes = ["Secrets\n(get/list)", "Pods\n(exec)", "Deployments\n(create)", "ClusterRoles\n(bind)", "Nodes\n(get)", "ConfigMaps\n(read/write)"]
# 0=aucun accès, 1=accès limité/sûr, 2=accès large/risqué, 3=accès dangereux
matrix = np.array([
[3, 3, 3, 3, 2, 3], # ci-pipeline cluster-admin
[0, 1, 0, 0, 1, 1], # monitoring pod-reader
[0, 0, 0, 0, 0, 1], # app-backend minimal
[0, 0, 1, 0, 1, 0], # ingress-ctrl
[2, 0, 0, 0, 0, 2], # db-operator
])
cmap = sns.color_palette(["#2ecc71", "#f1c40f", "#e67e22", "#e74c3c"], as_cmap=False)
colors_mapped = [[cmap[v] for v in row] for row in matrix]
fig, ax = plt.subplots(figsize=(11, 5))
labels_text = {0: "Aucun", 1: "Limité", 2: "Large", 3: "Critique"}
annot = np.array([[labels_text[v] for v in row] for row in matrix])
for i, row in enumerate(matrix):
for j, val in enumerate(row):
color = cmap[val]
ax.add_patch(plt.Rectangle([j - 0.5, i - 0.5], 1, 1, color=color, alpha=0.85))
text_color = "white" if val >= 2 else "#2c3e50"
ax.text(j, i, labels_text[val], ha="center", va="center",
fontsize=9, fontweight="bold", color=text_color)
ax.set_xticks(range(len(ressources_verbes)))
ax.set_yticks(range(len(service_accounts)))
ax.set_xticklabels(ressources_verbes, fontsize=9)
ax.set_yticklabels(service_accounts, fontsize=9)
ax.set_xlim(-0.5, len(ressources_verbes) - 0.5)
ax.set_ylim(-0.5, len(service_accounts) - 0.5)
ax.set_title("Matrice RBAC Kubernetes — ServiceAccounts × Ressources", fontsize=13, pad=15)
ax.invert_yaxis()
legend_patches = [mpatches.Patch(color=cmap[i], label=l)
for i, l in enumerate(["Aucun accès", "Accès limité (sûr)", "Accès large (risqué)", "Accès critique (dangereux)"])]
ax.legend(handles=legend_patches, loc="upper right", bbox_to_anchor=(1.42, 1), fontsize=9)
sns.despine(left=True, bottom=True)
plt.savefig("k8s_rbac_matrix.png", dpi=100, bbox_inches="tight")
plt.show()
Graphe des Network Policies#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
G = nx.DiGraph()
pods = {
"Internet": "#95a5a6",
"Ingress\nController": "#3498db",
"Frontend": "#2ecc71",
"Backend": "#27ae60",
"Database": "#e74c3c",
"Monitoring\n(Prometheus)": "#9b59b6",
"Redis\n(Cache)": "#e67e22",
}
for pod in pods:
G.add_node(pod)
# Flux autorisés (allowed=True) et bloqués (allowed=False)
edges = [
("Internet", "Ingress\nController", True),
("Ingress\nController", "Frontend", True),
("Ingress\nController", "Backend", True),
("Frontend", "Backend", True),
("Backend", "Database", True),
("Backend", "Redis\n(Cache)", True),
("Monitoring\n(Prometheus)", "Frontend", True),
("Monitoring\n(Prometheus)", "Backend", True),
("Monitoring\n(Prometheus)", "Database", True),
# Flux bloqués par Network Policies
("Frontend", "Database", False),
("Frontend", "Redis\n(Cache)", False),
("Internet", "Backend", False),
("Internet", "Database", False),
]
pos = {
"Internet": (0, 2),
"Ingress\nController": (2, 2),
"Frontend": (4, 3),
"Backend": (4, 1),
"Database": (6.5, 1),
"Redis\n(Cache)": (6.5, 2.5),
"Monitoring\n(Prometheus)": (1, 0),
}
fig, ax = plt.subplots(figsize=(13, 6))
ax.set_facecolor("#f8f9fa")
node_colors = [pods[n] for n in G.nodes()]
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=2200,
ax=ax, alpha=0.9)
nx.draw_networkx_labels(G, pos, font_size=8, font_color="white",
font_weight="bold", ax=ax)
allowed_edges = [(u, v) for u, v, a in edges if a]
blocked_edges = [(u, v) for u, v, a in edges if not a]
nx.draw_networkx_edges(G, pos, edgelist=allowed_edges,
edge_color="#2ecc71", arrows=True,
arrowsize=20, width=2.5, ax=ax,
connectionstyle="arc3,rad=0.05")
nx.draw_networkx_edges(G, pos, edgelist=blocked_edges,
edge_color="#e74c3c", arrows=True,
arrowsize=20, width=2, ax=ax,
style="dashed", connectionstyle="arc3,rad=0.1")
green_patch = mpatches.Patch(color="#2ecc71", label="Flux autorisé (NetworkPolicy)")
red_patch = mpatches.Patch(color="#e74c3c", label="Flux bloqué (default-deny)")
ax.legend(handles=[green_patch, red_patch], loc="lower right", fontsize=10)
ax.set_title("Topologie réseau Kubernetes avec Network Policies", fontsize=13, pad=15)
ax.axis("off")
plt.savefig("k8s_network_policies.png", dpi=100, bbox_inches="tight")
plt.show()
Timeline d’une séquence d’attaque Kubernetes#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.0)
evenements = [
(0, "Énumération API", "GET /api/v1/namespaces", "Reconnaissance", "#3498db"),
(1, "Listing ServiceAccounts", "GET /api/v1/serviceaccounts", "Reconnaissance", "#3498db"),
(2, "Accès token SA default", "GET /secrets (token automontage)", "Escalade", "#e67e22"),
(3, "Listing pods système", "GET /api/v1/pods (kube-system)", "Reconnaissance", "#3498db"),
(4, "Exec dans pod", "POST /api/v1/pods/etcd/exec", "Intrusion", "#e74c3c"),
(5, "Lecture etcd", "etcdctl get / --prefix (secrets cluster)", "Exfiltration", "#c0392b"),
(6, "Création pod privilégié", "POST /api/v1/pods (hostPID + hostPath /)", "Évasion conteneur","#8e44ad"),
(7, "Accès nœud hôte", "chroot /host (filesystem nœud)", "Compromission nœud","#6c3483"),
]
fig, ax = plt.subplots(figsize=(13, 6))
ax.set_facecolor("#1a1a2e")
phases_colors = {
"Reconnaissance": "#3498db",
"Escalade": "#e67e22",
"Intrusion": "#e74c3c",
"Exfiltration": "#c0392b",
"Évasion conteneur": "#8e44ad",
"Compromission nœud": "#6c3483",
}
y_positions = {"Reconnaissance": 3, "Escalade": 2, "Intrusion": 1,
"Exfiltration": 0.5, "Évasion conteneur": -0.5, "Compromission nœud": -1.5}
for t, nom, detail, phase, couleur in evenements:
y = y_positions[phase]
ax.scatter(t, y, s=200, color=couleur, zorder=5, edgecolors="white", linewidths=1.5)
offset_y = 0.25 if t % 2 == 0 else -0.35
ax.annotate(f"t+{t}m\n{nom}", (t, y),
xytext=(t, y + offset_y),
fontsize=7.5, color="white", ha="center", va="center",
fontweight="bold")
ax.annotate(detail, (t, y),
xytext=(t, y + offset_y - 0.28),
fontsize=6.5, color="#bdc3c7", ha="center", va="top", style="italic")
# Ligne de temps
ax.axhline(y=3, color="#3498db", alpha=0.3, linewidth=1.5, linestyle="--")
ax.axhline(y=2, color="#e67e22", alpha=0.3, linewidth=1.5, linestyle="--")
ax.axhline(y=1, color="#e74c3c", alpha=0.3, linewidth=1.5, linestyle="--")
ax.axhline(y=0.5, color="#c0392b", alpha=0.3, linewidth=1.5, linestyle="--")
ax.axhline(y=-0.5, color="#8e44ad", alpha=0.3, linewidth=1.5, linestyle="--")
ax.axhline(y=-1.5, color="#6c3483", alpha=0.3, linewidth=1.5, linestyle="--")
# Labels des phases
for phase, y in y_positions.items():
ax.text(-0.6, y, phase, fontsize=8, color=phases_colors[phase],
ha="right", va="center", fontweight="bold")
ax.set_xlim(-1, 8)
ax.set_ylim(-2.5, 4)
ax.set_xlabel("Temps (minutes depuis la compromission initiale)", color="white", fontsize=10)
ax.set_title("Timeline d'une attaque Kubernetes : énumération → évasion de conteneur",
fontsize=12, color="white", pad=15)
ax.tick_params(colors="white")
ax.spines["bottom"].set_color("#4a4a6a")
for spine in ["top", "left", "right"]:
ax.spines[spine].set_visible(False)
ax.set_yticks([])
plt.savefig("k8s_attack_timeline.png", dpi=100, bbox_inches="tight", facecolor="#1a1a2e")
plt.show()
Résumé#
Surface d’attaque multi-composants : l’API Server, etcd (chiffrement au repos obligatoire), le kubelet (port 10250 non authentifié) et les nœuds constituent autant de vecteurs d’entrée distincts.
RBAC granulaire : ServiceAccounts dédiés par application, rôles au scope minimum, désactivation du token automontage par défaut, bannissement de
cluster-adminhors administrateurs humains.Verbes dangereux :
bind,escalate,impersonateetexecpermettent une escalade de privilèges directe — les surveiller avec des outils commekubectl-who-canou rbac-police.Network Policies : adopter un modèle default-deny puis ouvrir explicitement les flux nécessaires. Exige un CNI compatible (Calico, Cilium).
Pod Security Standards : le niveau
restrictedimpose runAsNonRoot, readOnlyRootFilesystem, drop capabilities et seccomp. Activé via labels de namespace, facile à auditer.Admission controllers : OPA/Gatekeeper (Rego) et Kyverno (YAML natif) permettent de définir des politiques guardrails au niveau cluster, bloquant les déploiements non conformes avant création.
Audit logging : enregistrer systématiquement les opérations sur les secrets, les execs de pods et les mutations de RBAC. Analyser avec des outils SIEM (Falco, Elastic) pour détecter les séquences d’attaque.