FastAPI Hexagonal Code source

07. Sécurité

Trois securityContext ne font pas une sécurité Kubernetes. Ce qu'il fallait ajouter, et ce qui manque encore.

TL;DR — Le chapitre 02 durcit les conteneurs. Ici on ferme trois autres portes : chaque workload a son ServiceAccount sans jeton monté, le namespace applique Pod Security Admission (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 ?

QuestionRéponse avantRéponse maintenant
Peut-il appeler l’API Kubernetes ?Jeton monté par défaut, droits limités par RBACPas 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 namespacePod Security Admission refuse
Peut-il exfiltrer vers Internet ?Oui, l'egress était totalement libreConnexions 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: false

Cinq 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)
Le meilleur RBAC est celui qu'on n'écrit pas

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 :

ModeEffetQuand s'en servir
enforceRefuse la création du podCe qu'on sait déjà tenir
auditÉcrit dans l'audit log du clusterMesurer sans rien casser
warnRenvoie un avertissement à qui fait le kubectl applyRendre 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 apply
Pourquoi pas enforce: 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 :

WorkloadCe que restricted lui reproche
valkeyallowPrivilegeEscalation != false
unrestricted capabilities (pas de drop: [ALL])
runAsNonRoot != true
seccompProfile 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 Internet

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

Le DNS d'abord, ou rien ne marche

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.

Accès éventuel à l’API server. Sa topologie et l’ordre de traduction des IP varient selon le cluster et son réseau. Une règle vers l’IP du nœud sur 6443 n’est pas une recette portable : vérifier le Service 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 :

DepuisVersAttenduObservé
apiDNS (résolution d'un Service)passerésolu
apitask-manager-pooler:5432passeconnecté
api1.1.1.1:443 (Internet)refusétimeout
frontendtask-manager-pooler:5432refusé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#

AbsentPourquoi ça compte
enforce: restrictedQuatre images tierces à durcir d'abord (voir plus haut)
Images par digestDeux images en :latest : le déploiement n'est pas reproductible
Scan de vulnérabilités, SBOM, signatureRien ne vérifie ce qui entre dans le cluster
Secrets chiffréssecrets.yaml contient des change-me en clair dans git
TLS internePas de chiffrement applicatif systématiquement configuré ; le chiffrement de chaque liaison doit être vérifié
Egress des pods d'opérateursLe trou assumé décrit plus haut

À retenir#