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

ServiceAccount

Namespace

Identité d’un pod

Role

Namespace

Permissions sur des ressources namespaced

ClusterRole

Cluster

Permissions sur des ressources cluster-wide

RoleBinding

Namespace

Lie un sujet à un Role (ou ClusterRole)

ClusterRoleBinding

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

bind

clusterrolebindings

Peut s’attribuer n’importe quel rôle

escalate

roles/clusterroles

Peut modifier un rôle pour s’accorder des droits

impersonate

users/groups/serviceaccounts

Peut agir en tant qu’un autre sujet

exec

pods

Exécution de commandes dans n’importe quel pod

create

pods

Peut lancer un pod --privileged

Anti-patterns RBAC#

Anti-patterns RBAC courants

  • cluster-admin généralisé : attribuer cluster-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 default avec un token. Désactiver avec automountServiceAccountToken: false.

  • Wildcards dans les resources : resources: ["*"] accorde l’accès à des ressources futures non anticipées.

  • Bindings cross-namespace : utiliser un ClusterRoleBinding quand un RoleBinding suffit é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é

Privileged

Aucune restriction

Nœuds système, CNI plugins

Baseline

Prévient les escalades connues

Applications générales

Restricted

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é

None

Rien

Metadata

Métadonnées de la requête (qui, quoi, quand)

Request

Métadonnées + corps de la requête

RequestResponse

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#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import pandas as pd
import numpy as np
import networkx as nx

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()
_images/22a346a4e7bf975987b21d19b96fce8eff3170265471a3a58c6ad55187ae21bc.png

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()
_images/89212b1ebf3c05646d3c612616e96d5902c0f8473368b983ce7bd09ba6b2f335.png

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()
_images/b6b1e0ae1fa924f240a8db37aeab17852d2275f1476d08c08f6b3976ec0f515a.png

Résumé#

  1. 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.

  2. RBAC granulaire : ServiceAccounts dédiés par application, rôles au scope minimum, désactivation du token automontage par défaut, bannissement de cluster-admin hors administrateurs humains.

  3. Verbes dangereux : bind, escalate, impersonate et exec permettent une escalade de privilèges directe — les surveiller avec des outils comme kubectl-who-can ou rbac-police.

  4. Network Policies : adopter un modèle default-deny puis ouvrir explicitement les flux nécessaires. Exige un CNI compatible (Calico, Cilium).

  5. Pod Security Standards : le niveau restricted impose runAsNonRoot, readOnlyRootFilesystem, drop capabilities et seccomp. Activé via labels de namespace, facile à auditer.

  6. 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.

  7. 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.