07. Sécurité
Trois securityContext ne font pas une sécurité
Kubernetes. Ce qu'il fallait ajouter, et ce qui manque encore.
baseline imposé,
restricted en avertissement), et l'egress n'est plus
libre — les quatre workloads ciblés ne peuvent plus ouvrir librement des connexions directes vers Internet. Le meilleur RBAC est
celui qu'on n'écrit pas.
Ce que le durcissement de conteneur ne couvre pas#
Le chapitre 02 pose
runAsNonRoot, readOnlyRootFilesystem,
capabilities.drop: [ALL], seccompProfile. C'est bien, et
c'est insuffisant : tout cela protège contre une évasion du conteneur. Rien
n'y répond à la question suivante — que peut faire un attaquant qui
obtient une exécution de code à l'intérieur du conteneur, sans en sortir ?
| Question | Réponse avant | Réponse maintenant |
|---|---|---|
| Peut-il appeler l’API Kubernetes ? | Jeton monté par défaut, droits limités par RBAC | Pas de jeton automatique pour les cinq ServiceAccounts déclarés ; cela ne bloque pas le réseau ni un autre identifiant |
| Peut-il déployer un pod privilégié ? | Rien ne l'en empêchait au niveau du namespace | Pod Security Admission refuse |
| Peut-il exfiltrer vers Internet ? | Oui, l'egress était totalement libre | Connexions directes limitées pour les pods sélectionnés ; DNS et destinations autorisées restent des canaux possibles |
Le ServiceAccount : ne pas monter ce qui ne sert pas#
Les cinq ServiceAccounts applicatifs déclarés dans rbac.yaml n’ont pas besoin d’appeler Kubernetes. Les ressources gérées par les opérateurs suivent leurs propres besoins et permissions. Un jeton permet l’authentification, pas tous les droits : en configuration standard, le ServiceAccount default n’autorise pas à créer des pods privilégiés sans permission RBAC correspondante.
# k8s/rbac.yaml — une identité par workload, aucun droit accordé
apiVersion: v1
kind: ServiceAccount
metadata: { name: api, namespace: task-manager }
automountServiceAccountToken: falseCinq au total : api, mail-worker,
frontend, prometheus, et migrate pour le Job
de migration. Ce dernier est éphémère, ce qui ne change rien à la règle : il ne
parle qu'à PostgreSQL, il n'a donc pas besoin de jeton. Un workload qu'on oublie
parce qu'il est court est exactement celui par lequel on se fait avoir.
Puis, dans chaque Deployment, serviceAccountName: api.
Le résultat se constate :
$ kubectl exec -n task-manager api-… -- ls /var/run/secrets/kubernetes.io/serviceaccount
NON (attendu)On pourrait s'attendre à trouver ici des Role et
RoleBinding. Il n'y en a aucun, et c'est le propos :
ces workloads n'ont besoin d'aucun droit. Créer un ServiceAccount nommé sans lui
donner de permission n'est pas un travail à moitié fait — c'est le résultat
recherché. L'intérêt du nommage est de préparer le jour où l'un d'eux aura
vraiment besoin d'un droit : on le donnera à lui, pas à tout le
namespace.
La forme qu'aurait ce jour-là un Role minimal :
# NON appliqué — la forme, pour référence
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: api-lecture-config, namespace: task-manager }
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["api-config"] # nommer la ressource, pas seulement le type
verbs: ["get"]Deux réflexes s'y lisent : resourceNames pour ne pas ouvrir toutes
les ConfigMap du namespace, et un Role (namespacé) plutôt qu'un
ClusterRole.
Prometheus mérite une note à part : il scrape une cible statique
(api:8000), pas une découverte Kubernetes. Il n'a donc pas besoin de
lire l'API server non plus. Le jour où l'on passe en
kubernetes_sd_configs, c'est le seul workload dont le jeton
redeviendrait nécessaire — avec un Role de lecture des pods et
services.
Pod Security Admission : viser restricted sans casser le déploiement#
PSA remplace les PodSecurityPolicy, retirées en 1.25. C'est un
contrôleur d'admission qui compare chaque pod à un profil. Il se configure par de
simples labels de namespace, avec trois modes indépendants :
| Mode | Effet | Quand s'en servir |
|---|---|---|
enforce | Refuse la création du pod | Ce qu'on sait déjà tenir |
audit | Écrit dans l'audit log du cluster | Mesurer sans rien casser |
warn | Renvoie un avertissement à qui fait le kubectl apply | Rendre l'écart visible tout de suite |
# k8s/namespace.yaml
labels:
pod-security.kubernetes.io/enforce: baseline # ce qui passe aujourd'hui
pod-security.kubernetes.io/audit: restricted # ce qu'on vise
pod-security.kubernetes.io/warn: restricted # visible dès le kubectl applyenforce: restricted tout de suite
Parce que le namespace deviendrait indéployable. Nos workloads applicatifs
respectent restricted, mais quatre images tierces tournent en
configuration par défaut. Les imposer aujourd'hui, c'est un déploiement qui
échoue — et la tentation de tout retirer.
C'est là que warn vaut son pesant d'or : il donne la liste exacte
du travail restant, à chaque kubectl apply, sans rien
bloquer.
Voici ce que le déploiement affiche réellement, aujourd'hui :
| Workload | Ce que restricted lui reproche |
|---|---|
valkey | allowPrivilegeEscalation != falseunrestricted capabilities (pas de drop: [ALL])runAsNonRoot != trueseccompProfile non défini |
mailpit | |
prometheus | |
redisinsight |
Les quatre échouent sur les mêmes quatre critères, ce qui est une
bonne nouvelle : c'est un travail répétitif, pas quatre problèmes distincts. Rien
n'interdit de le faire — il faut simplement vérifier image par image que le
processus tolère un UID non-root, ce qui n'est pas acquis pour des images qui ne
déclarent pas de USER.
Deux limites à énoncer franchement. D'abord, les pods gérés par des
opérateurs (CloudNativePG, RabbitMQ) ne sont pas sous notre contrôle
direct : leur securityContext vient de l'opérateur. Ensuite,
baseline est une barre basse — il interdit le vraiment dangereux
(privileged, hostNetwork, hostPath), pas
grand-chose de plus. Le dire, c'est éviter de confondre « PSA activé » et
« namespace durci ».
Fermer l'egress#
Le chapitre 04 décrit neuf politiques d'ingress : qui a le droit de joindre quoi. L'egress, lui, était resté totalement ouvert — ce que la documentation disait honnêtement, mais qui laissait la porte grande ouverte à l'exfiltration :
API compromise → n'importe quelle destination sur InternetQuatre politiques d'egress ferment ce chemin, une par workload que
nous possédons : frontend, api,
mail-worker, prometheus. Les NetworkPolicy étant additives,
chaque objet reste lisible séparément : une politique d'ingress par cible, une
politique d'egress par source.
Un pod isolé en egress qui ne peut plus joindre CoreDNS ne résout plus
task-manager-pooler, ni rabbitmq, ni rien. Le symptôme
n'est pas « accès refusé » mais un timeout à la première connexion,
et il ressemble trait pour trait à une panne de la base de données. C'est l'oubli
numéro un sur les politiques d'egress.
# k8s/network-policies.yaml — toute politique d'egress commence par ça
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 }Les deux sélecteurs sont dans le même élément de liste : c'est un
ET (ce namespace et ces pods). Dans deux éléments séparés, ce serait un OU
— et la politique autoriserait tout le namespace kube-system.
UDP et TCP, parce que les réponses DNS volumineuses basculent en TCP.
kubernetes, ses endpoints, le port effectif et le comportement du moteur de politiques avant d’autoriser ce flux.Ce qu'on ne ferme volontairement pas#
Les pods gérés par opérateurs — instances PostgreSQL, pooler, nœuds RabbitMQ — gardent un egress libre. Leur trafic sortant (réplication, clustering, appels à l'API server, sondes) appartient à l'opérateur et change avec ses versions. Le fermer depuis l'extérieur, c'est casser le cluster sans rien apprendre. C'est un choix, il est assumé, et il laisse un trou : un attaquant qui prendrait la main sur un pod PostgreSQL ne serait pas contraint en sortie.
Vérifier que ça refuse#
Une politique qu'on n'a pas testée ne prouve rien — et une NetworkPolicy non appliquée par le CNI échoue en silence. Les quatre contrôles qui comptent :
| Depuis | Vers | Attendu | Observé |
|---|---|---|---|
api | DNS (résolution d'un Service) | passe | résolu |
api | task-manager-pooler:5432 | passe | connecté |
api | 1.1.1.1:443 (Internet) | refusé | timeout |
frontend | task-manager-pooler:5432 | refusé | timeout |
Le timeout vers 1.1.1.1:443 vérifie une connexion directe précise, à condition qu’un contrôle sans restriction démontre que la cible répond. Il ne prouve pas l’impossibilité d’exfiltrer : le DNS autorisé, les services joignables et les réponses HTTP restent des canaux possibles. Les politiques réduisent la surface réseau ; elles ne remplacent pas les contrôles applicatifs.
Ce qui manque encore#
| Absent | Pourquoi ça compte |
|---|---|
enforce: restricted | Quatre images tierces à durcir d'abord (voir plus haut) |
| Images par digest | Deux images en :latest : le déploiement n'est pas reproductible |
| Scan de vulnérabilités, SBOM, signature | Rien ne vérifie ce qui entre dans le cluster |
| Secrets chiffrés | secrets.yaml contient des change-me en clair dans git |
| TLS interne | Pas de chiffrement applicatif systématiquement configuré ; le chiffrement de chaque liaison doit être vérifié |
| Egress des pods d'opérateurs | Le trou assumé décrit plus haut |
À retenir#
- Le
securityContextprotège de l'évasion du conteneur. Il ne dit rien de ce qu'un attaquant peut faire depuis l'intérieur. - Le jeton du ServiceAccount
defaultest monté d'office : c'est un identifiant offert.automountServiceAccountToken: falseest le geste le plus rentable du chapitre. - Le meilleur RBAC est celui qu'on n'écrit pas : un ServiceAccount nommé sans aucun droit n'est pas un travail inachevé.
- Les modes PSA sont indépendants : imposer le profil déjà supporté et utiliser
warnpour préparer un profil plus strict. Un nouveau namespace peut directement imposerrestrictedsi tous ses workloads le respectent. - Une politique d'egress qui oublie le DNS casse tout, et le symptôme ressemble à une panne de base de données.
- Une politique non testée ne prouve rien : il faut vérifier qu'elle refuse, pas seulement qu'elle n'a rien cassé.