Un conteneur n'est pas une machine virtuelle
Jour 1 · L'image : ce qu'elle contient vraiment · 25 min
Vous manipulez des conteneurs depuis le premier jour du parcours. Il faut maintenant savoir ce qu'ils sont réellement, parce que trois jours de durcissement en découlent.
Un conteneur n'est pas une machine légère. C'est un processus ordinaire de votre hôte, lancé avec un jeu de restrictions du noyau Linux. Il n'y a pas de second système d'exploitation : il y a un seul noyau, et il est partagé.
Les trois mécanismes, et ce que chacun fait
| Mécanisme | Ce qu'il règle | Exemple concret |
|---|---|---|
| namespaces | Ce que le processus voit | Le conteneur ne voit que ses propres processus, ses propres montages, sa propre pile réseau |
| cgroups | Ce que le processus consomme | Plafond mémoire, part de CPU, débit disque |
| capabilities | Ce que le processus peut | CAP_NET_BIND_SERVICE pour ouvrir un port < 1024, CAP_SYS_ADMIN pour monter un système de fichiers |
Ajoutez à cela seccomp, qui filtre les appels système autorisés, et éventuellement AppArmor ou SELinux. Aucun de ces mécanismes ne crée une frontière matérielle : ce sont des restrictions posées par le noyau, sur des processus que le noyau exécute lui-même.
Être root dans un conteneur, ce n'est pas être root « dans une petite machine à part ». C'est être uid 0 vis-à-vis du noyau de l'hôte, avec une vue restreinte — restriction que quelques options de docker run suffisent à lever.
Une faille d'échappement de conteneur n'est donc pas une curiosité théorique : c'est le prolongement normal de cette architecture. Cinq minutes de TP suffisent à le montrer, sans aucune faille.
Ce que cela change pour votre travail
- Un conteneur ne doit pas tourner en root, parce que la cloison est plus mince qu'un hyperviseur. C'est le geste du jour.
- Chaque paquet installé dans l'image est du code exécutable sur l'hôte. Réduire l'image n'est pas de l'esthétique : c'est réduire la surface d'attaque.
- Le multi-tenant strict n'est pas un usage de Docker seul. Faire tourner le code d'un client hostile à côté d'un autre demande une isolation par machine virtuelle légère — gVisor, Kata Containers, Firecracker. Hors programme, mais à savoir citer.
Docker Desktop fait tourner une machine virtuelle Linux, et vos conteneurs sont des processus de cette VM. Il y a donc bien un noyau Linux partagé — celui de la VM. Tout ce qui suit reste vrai, à ceci près que « l'hôte » compromis est la VM Docker et non votre système. Sur un serveur de production Linux, il n'y a pas cette VM intermédiaire : l'hôte est la machine.
Intention — remplacer le modèle mental « petite VM » par le bon : un processus de l'hôte à qui l'on a restreint la vue. Tout le module en dépend. Le durcissement d'image (J1), le refus de PSA (J2) et l'inutilité du readOnlyRootFilesystem sur un conteneur privilégié (J2) sont incompréhensibles tant que le noyau est cru dupliqué.
Ce qui coince
- « Le conteneur a son propre système, donc être root dedans est sans conséquence. » C'est l'erreur centrale, et elle survit à une explication : elle ne tombe qu'après le TP 1, quand il lit un fichier de l'hôte.
- Confusion entre les trois mécanismes. Signe : il attribue à cgroups la séparation des processus, ou aux capabilities la limite de mémoire. Faire reformuler en trois phrases : ce qu'on voit, ce qu'on consomme, ce qu'on peut.
- Sur macOS, l'idée qu'il n'y a pas de noyau Linux du tout. Il y en a un, celui de la VM de Docker Desktop — et c'est bien lui, l'hôte à compromettre.
Questions à anticiper
- « Alors les conteneurs ne servent à rien pour la sécurité ? » — si : ils réduisent la surface et rendent les privilèges explicites. Ce sont des cloisons, pas des murs porteurs. Ne pas laisser passer le nihilisme, il conduit à ne rien durcir.
- « gVisor, Kata, Firecracker ? » — oui, ce sont exactement les réponses à ce problème ; les citer, ne rien déployer, aucun n'est au programme.
Ce qu'il faut exiger — qu'il énonce la conséquence pratique avant le TP : « root dans le conteneur, c'est uid 0 sur le noyau de l'hôte ». S'il ne peut pas le dire, le TP 1 sera une suite de commandes sans portée.
TP 1 — Prouver que le noyau est partagé
Jour 1 · L'image : ce qu'elle contient vraiment · 50 min
Démontrer, sans exploiter la moindre faille, qu'un conteneur voit et touche l'hôte dès qu'on lui en donne le droit.
Toutes les commandes de ce module s'exécutent sur votre poste et sur les cibles que vous y avez déployées. Le cadre posé au M1 tient : jamais sur Internet, jamais sur une ressource de Caplogy, jamais sur le système d'un tiers. Ce TP ne fait rien d'illégal — il utilise des options documentées de Docker sur votre propre machine.
- Comparez le noyau vu de l'intérieur et de l'extérieur. Une machine virtuelle afficherait deux versions différentes ; ici, la chaîne est identique au caractère près.
uname -r docker run --rm alpine:3.20 uname -r - Faites voir au conteneur les processus de l'hôte. Sans
--pid=host, il n'en voit qu'un — le sien. Avec, il voit tout ce que le noyau exécute.docker run --rm alpine:3.20 ps -ef echo '--- puis, en partageant l espace de processus de l hote ---' docker run --rm --pid=host alpine:3.20 ps -ef | head -20 - Montez la racine de l'hôte et lisez un fichier qui devrait être hors de portée. Aucune faille : le conteneur tourne en
root, et on lui a monté le disque.docker run --rm -v /:/hote:ro alpine:3.20 \ sh -c 'cat /hote/etc/os-release; echo ---; head -3 /hote/etc/shadow' - Regardez ce que le conteneur a le droit de faire, avec et sans
--privileged. La liste des capabilities passe d'une quinzaine à la totalité.docker run --rm alpine:3.20 sh -c 'apk add -q libcap && capsh --print' \ | grep -i 'current' docker run --rm --privileged alpine:3.20 \ sh -c 'apk add -q libcap && capsh --print' | grep -i 'current' - Le cas le plus courant en entreprise, et le plus mal compris : un conteneur à qui l'on a monté le socket du démon Docker. Il peut alors créer d'autres conteneurs — y compris un conteneur privilégié montant l'hôte.
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ docker:27-cli docker ps - Vous venez de lister, depuis un conteneur, tous les conteneurs de l'hôte. Notez la conclusion : monter
/var/run/docker.sockéquivaut à donner lerootde l'hôte. Vous le referez au TP 3 pour Trivy — en connaissance de cause, et sur votre poste seulement. - Consignez les trois preuves dans votre journal, avec la commande en regard de chaque observation.
cat >> journal-incidents.md <<'EOF' ## 2026-08-11 — Isolation des conteneurs (M3 J1) - Noyau partage : `uname -r` identique dedans et dehors -> <coller la valeur> - Processus de l hote visibles avec `--pid=host` -> <coller 2 lignes> - Disque de l hote lisible avec `-v /:/hote:ro` -> <coller /etc/os-release> - Socket Docker monte = root sur l hote (verifie avec docker:27-cli) EOF
- Vous n'avez exploité aucune vulnérabilité. Pourquoi parle-t-on quand même d'escalade de privilèges ?
- Une équipe vous demande de monter
/var/run/docker.sockdans un agent de CI pour qu'il puisse construire des images. Que répondez-vous, et que proposez-vous à la place ? - Si l'isolation est aussi fine, à quoi sert-il de faire tourner l'application en utilisateur non privilégié dans le conteneur ?
Rien de ce que vous allez faire ici n'est une attaque : ce sont des options documentées de docker run. C'est précisément le point. Un attaquant qui obtient l'exécution de commandes sur une machine où il peut parler au démon Docker n'a plus besoin de faille : il lui suffit de lire la documentation.
Sous macOS, l'hôte que vous atteignez est la VM Linux de Docker Desktop, pas votre Mac. Ne concluez pas que la démonstration a échoué parce que vous ne reconnaissez pas vos fichiers : regardez /etc/os-release, vous y lirez une distribution que vous n'avez jamais installée. C'est bien un système hôte, et c'est bien le vôtre qui l'héberge.
Intention — faire tomber le modèle « petite VM » par l'observation, pas par l'argument. Trois preuves indépendantes : même noyau, mêmes processus, même disque. L'étape 5 (socket Docker) est celle qui doit rester en mémoire, parce qu'il la reproduira sans y penser au TP 3 en montant le socket pour Trivy.
Ce qui coince
- macOS :
cat /hote/etc/shadowaffiche un fichier inconnu, il croit que ça n'a pas marché. Lui faire lire/hote/etc/os-release. --pid=hostrefusé par certaines configurations durcies de Docker Desktop. Le contournement n'est pas l'objectif : passer à l'étape suivante, les autres preuves suffisent.- Il conclut « donc Docker est dangereux » et s'arrête là. Rediriger : la question n'est pas si, c'est quels privilèges on donne. La suite du jour est la réponse.
Questions à anticiper
- « Pourquoi
--privilegedexiste-t-il, si c'est si grave ? » — pour les conteneurs d'infrastructure : pilotes, montages, kind lui-même. Bonne occasion de dire que le cluster de demain tourne dans un conteneur privilégié. - «
userns-remaprègle-t-il le problème ? » — il le réduit beaucoup ; il n'est pas activé par défaut et casse certains montages. Le citer, ne pas l'activer : ce serait une variable de plus dans les diagnostics des trois jours.
Ce qu'il faut exiger — trois observations notées dans journal-incidents.md, avec la commande exacte en regard. Pas « j'ai vu les processus de l'hôte » mais la commande et les deux premières lignes de sa sortie.
Anatomie d'une image : couches, digest, et ce qui ne s'efface pas
Jour 1 · L'image : ce qu'elle contient vraiment · 25 min
Une image Docker n'est pas un fichier : c'est une pile de couches en lecture
seule, plus un peu de métadonnées. Chaque instruction du Dockerfile qui
modifie le système de fichiers ajoute une couche par-dessus les précédentes.
La propriété qui surprend tout le monde
Les couches s'empilent, elles ne se remplacent pas. Quand une instruction supprime un
fichier, la couche du dessus n'efface rien : elle dépose un marqueur — un
whiteout, nommé .wh.<fichier> — qui dit « à partir d'ici, ce
fichier n'existe plus ». Le contenu, lui, reste intégralement dans la couche du
dessous, et quiconque récupère l'image peut l'extraire.
Copier un fichier de secrets, l'utiliser, puis le supprimer dans une instruction suivante ne le retire pas de l'image. Le geste ne trompe que celui qui l'écrit. Vous le vérifierez dans dix minutes sur une image de trois lignes.
Tag, digest, ID : trois choses différentes
| Identifiant | Ce qu'il désigne | Stable ? |
|---|---|---|
Tag — python:3.12 | Une étiquette mobile, repointée par le publieur | Non : le même tag désigne un contenu différent la semaine suivante |
Digest — sha256:… | Le condensat du manifeste, donc du contenu exact | Oui : un digest désigne un seul contenu, pour toujours |
Image ID — sha256:… | Le condensat de la configuration locale, après téléchargement | Local, différent du digest, sans usage pour l'épinglage |
Conséquence directe sur votre travail : un rapport de scan qui dit « image
python:3.12, zéro CVE critique » n'a aucune valeur juridique ni technique s'il ne
porte pas le digest. Une chaîne de production sérieuse épingle par digest :
FROM python:3.12-slim@sha256:<digest releve le jour du build>Les commandes de lecture
| Commande | Ce qu'elle montre |
|---|---|
docker history <image> | L'instruction à l'origine de chaque couche et sa taille |
docker image inspect <image> | L'utilisateur, les variables d'environnement, l'entrypoint, les digests |
docker save <image> -o img.tar | L'image entière sur disque, couche par couche, ouvrable avec tar |
docker run --rm <image> ls -a /app | Le résultat empilé, tel que le conteneur le voit — pas le contenu des couches |
docker run … ls vous montre la vue aplatie : ce que voit l'application. docker save + tar vous montre le contenu réel : ce qu'un attaquant qui récupère l'image peut extraire. Un audit d'image sérieux regarde le second.
Intention — installer deux idées qui commandent tout le durcissement : une couche est additive (supprimer n'efface pas), et un tag n'identifie rien. La première justifie .dockerignore et le multi-stage ; la seconde justifie l'épinglage par digest et rend lisible le résultat du scan.
Ce qui coince
- «
RUN rmsupprime le fichier de l'image. » Croyance très solide chez quelqu'un qui a l'habitude d'un système de fichiers normal. Elle ne cède qu'au TP 2, où il retrouve la chaîne dans une couche. - Le whiteout pris pour le fichier lui-même. Signe : il voit
.wh.secret.txtet croit avoir trouvé le secret. Lui faire ouvrir les deux couches. - Digest confondu avec l'ID d'image local. Deux valeurs différentes, deux usages : l'une identifie le contenu publié, l'autre la configuration locale.
Questions à anticiper
- « Pourquoi ne pas tout mettre dans un seul
RUN? » — c'est exactement la parade classique, et elle a un coût en cache. Le dire, sans en faire une règle : le multi-stage la rend souvent inutile. - «
docker system prunenettoie-t-il ça ? » — non, il supprime des images entières, pas le contenu d'une couche.
Ce qu'il faut exiger — qu'il prédise, avant le TP 2, ce que contiendra la couche du RUN rm. Une prédiction fausse énoncée à voix haute vaut mieux qu'une observation subie.
TP 2 — Ouvrir l'image et y trouver ce qui n'aurait pas dû y entrer
Jour 1 · L'image : ce qu'elle contient vraiment · 1 h
Extraire d'une image ce qu'une simple exécution ne montre pas, et le constater sur devsecops-lab:dev.
Vous partez du dépôt devsecops-lab tel que le M2 l'a laissé : les dépendances Python ont été corrigées (VULN-10), le Dockerfile est encore celui du M1. Reconstruisez l'image avant de commencer, pour être sûr d'auditer ce que contient votre dépôt aujourd'hui.
- Depuis la racine de
devsecops-lab, reconstruisez l'image de développement et relisez leDockerfiledu M1 : c'est lui que vous auditez.cd ~/devsecops-lab cat Dockerfile docker build -t devsecops-lab:dev . - Regardez la pile de couches. Repérez celle du
COPY . .et sa taille : c'est votre dépôt entier qui est entré dans l'image.docker history devsecops-lab:dev docker history --no-trunc devsecops-lab:dev | head -20 - Vérifiez la vue aplatie, celle que l'application voit. Vous devez y trouver
.envet.git.docker run --rm devsecops-lab:dev ls -a /app docker run --rm devsecops-lab:dev cat /app/.env docker run --rm devsecops-lab:dev sh -c 'du -sh /app/.git' - Arrêtez-vous une seconde sur ce que vous venez d'afficher.
.envcontient le faux secret posé au M1 ;.gitcontient l'historique complet du dépôt, y compris les commits antérieurs à la correction deVULN-05. Corriger un secret dans le code ne le retire pas de l'historique, et l'historique est dans l'image. - Passons à la démonstration de fond, sur une image de trois lignes qu'on jette ensuite. Construisez-la.
mkdir -p ~/demo-couches && cd ~/demo-couches cat > Dockerfile <<'EOF' FROM alpine:3.20 RUN echo "motdepasse=hunter2" > /tmp/secret.txt RUN rm /tmp/secret.txt EOF docker build -t fuite:demo . - La vue aplatie ne montre rien : le fichier a bien été supprimé du point de vue du conteneur.
docker run --rm fuite:demo ls -la /tmp - Ouvrez maintenant l'image couche par couche. Vous cherchez deux entrées :
tmp/secret.txtdans une couche, ettmp/.wh.secret.txtdans une autre.docker save fuite:demo -o /tmp/fuite.tar rm -rf /tmp/fuite && mkdir -p /tmp/fuite && tar -xf /tmp/fuite.tar -C /tmp/fuite # Selon la version de Docker, les couches sont des <hash>/layer.tar ou des # blobs/sha256/<hash>, compresses ou non. On essaie les deux lectures sur # chaque fichier, et on ne garde que celles qui repondent. find /tmp/fuite -type f | while read -r f; do tar -tf "$f" 2>/dev/null | grep -q 'tmp/secret' && echo "COUCHE (tar) : $f" tar -tzf "$f" 2>/dev/null | grep -q 'tmp/secret' && echo "COUCHE (gz) : $f" done - Extrayez le contenu de la couche qui porte
tmp/secret.txtet lisez-le. Remplacez le chemin par celui que la commande précédente vous a renvoyé.COUCHE=/tmp/fuite/<chemin renvoye ci-dessus> mkdir -p /tmp/extrait tar -xf "$COUCHE" -C /tmp/extrait 2>/dev/null \ || tar -xzf "$COUCHE" -C /tmp/extrait cat /tmp/extrait/tmp/secret.txt motdepasse=hunter2s'affiche. Le fichier a été supprimé dans leDockerfile, il n'apparaît pas dans le conteneur, et il est pourtant lisible par quiconque récupère l'image. Retenez la formule : ce qui entre dans une couche n'en sort plus.- Notez le constat, puis faites le ménage de la démonstration.
cat >> journal-incidents.md <<'EOF' ## 2026-08-11 — Contenu reel de l image (M3 J1) - devsecops-lab:dev embarque .env et .git (COPY . . sans .dockerignore) - Un fichier supprime par RUN rm reste lisible dans la couche precedente (verifie sur fuite:demo, entree tmp/secret.txt) - Correctif prevu ce jour : .dockerignore + build multi-stage EOF docker rmi fuite:demo rm -rf /tmp/fuite /tmp/fuite.tar /tmp/extrait ~/demo-couches
- Un collègue propose de résoudre le problème en ajoutant
RUN rm -rf /app/.gità la fin duDockerfile. Que répondez-vous ? - Quelle différence faites-vous désormais entre « le conteneur ne voit pas le fichier » et « le fichier n'est pas dans l'image » ?
- Votre image est poussée sur un registre interne accessible à toute l'entreprise. Qu'est-ce que cela change à la gravité de ce que vous venez de trouver ?
Deux couches vous intéressent dans la démonstration de fuite, et pas une : celle du RUN echo, qui contient tmp/secret.txt, et celle du RUN rm, qui contient tmp/.wh.secret.txt. Si vous n'en trouvez qu'une, vous n'avez pas fini de chercher — et si vous ne trouvez que le .wh., vous avez trouvé le marqueur de suppression, pas le secret.
Sur devsecops-lab:dev, .env et .git sont dans l'image parce que le Dockerfile du M1 fait COPY . . sans .dockerignore. Ce n'est pas une négligence de votre part : c'est le défaut délibéré que ce module referme. Notez-le maintenant, vous le corrigerez au TP 4.
Intention — produire la preuve matérielle qui rend le .dockerignore et le multi-stage non négociables au TP 4. Sans cette heure-là, le durcissement est vécu comme une bonne pratique de plus.
Ce qui coince
- Le format de
docker savevarie : héritage (<hash>/layer.tar) ou OCI (blobs/sha256/<hash>), compressé ou non. La boucle fournie traite les quatre cas ; s'il la réécrit et n'obtient rien, c'est presque toujours qu'il n'a testé qu'un seul cas. grep hunter2directement sur le tar : parfois ça marche, souvent non (compression). Il en conclut que le secret n'y est pas. Exiger la méthode par extraction.- Le
.gitdans l'image sous-estimé. Poser la question qui réveille : « qu'y a-t-il dans.gitaprès un commit contenantVULN-05? » — l'historique complet, secrets compris, même après correction.
Questions à anticiper
- «
docker exportmontrerait-il la même chose ? » — non :exportaplatit le conteneur et perd exactement ce qu'on cherche. Excellente occasion de séparersaveetexport. - «
--squashrègle le problème ? » — expérimental, jamais activé par défaut, et il ne dispense pas du.dockerignore.
Ce qu'il faut exiger — le chemin du fichier de couche où il a trouvé tmp/secret.txt, et la liste des entrées de /app dans devsecops-lab:dev avec .env et .git dedans.
Quiz — Isolation et anatomie des images
Jour 1 · L'image : ce qu'elle contient vraiment · 10 min
1. Vous lancez uname -r sur votre hôte Linux, puis dans un conteneur Alpine. Que voyez-vous ?
2. Un Dockerfile copie un fichier de clés, s'en sert, puis fait RUN rm cles.pem. Que contient l'image publiée ?
3. Votre rapport d'audit mentionne « image de base python:3.12-slim, scannée le 11 août ». Qu'est-ce qui manque pour qu'il soit rejouable ?
4. Le code de votre application est propre et ses dépendances Python ont été corrigées. Trivy remonte pourtant 40 vulnérabilités HIGH sur l'image. D'où viennent-elles le plus probablement ?
5. Que change réellement l'option --privileged sur un conteneur ?
Intention — vérifier quatre acquis séparés avant le scan : le noyau partagé (q1), l'additivité des couches (q2), tag contre digest (q3), l'origine réelle des CVE d'une image (q4), et le sens de --privileged (q5). C'est la ventilation qui informe, pas le score.
Ce qui coince
- q2 ratée : la leçon L2 n'a pas pris et le TP 4 sera exécuté sans conviction. À reprendre avant de le laisser partir — trois minutes sur le whiteout.
- q4 ratée dans le sens « c'est le code de l'application » : il va chercher les correctifs au mauvais endroit tout l'après-midi.
- q1 ratée est le signal le plus lourd : refaire la démonstration
uname -rdevant lui, elle prend trente secondes.
Questions à anticiper
- « La q3, en pratique, personne n'épingle par digest ? » — si, dans toute chaîne auditée. Répondre par l'usage réel plutôt que par le principe.
Ce qu'un échec révèle — 3/5 avec q1 et q2 justes : laissez continuer. 3/5 avec q2 fausse : reprenez, le reste de la journée en dépend.
TP 3 — Trivy sur l'image : lire un rapport plutôt que le subir
Jour 1 · L'image : ce qu'elle contient vraiment · 1 h 15
Produire un état des lieux chiffré de devsecops-lab:dev, et savoir dire d'où vient chaque famille de résultats.
Un artefact daté, artefacts/trivy-image-dev-2026-08-11.json, et un comptage que vous reporterez dans le tableau avant/après du TP 5. artefacts/ est dans votre .gitignore : ces fichiers ne se versionnent pas, ils s'archivent.
- Lancez le premier scan en sortie lisible. Le tout premier appel télécharge la base de vulnérabilités : laissez-le finir, le volume
trivy-cachefait que cela n'arrivera qu'une fois.mkdir -p artefacts docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v trivy-cache:/root/.cache/trivy \ -v "$(pwd)/artefacts":/artefacts \ aquasec/trivy:0.53.0 image \ --severity HIGH,CRITICAL \ --format table devsecops-lab:dev - En tête de sortie, Trivy affiche la cible (
devsecops-lab:dev (debian 12.x)) puis, plus bas, une seconde section pour les paquets Python. Ce sont deux univers différents : la première liste les paquets Debian de l'image de base, la seconde vos dépendances applicatives. Notez les deux totaux séparément. - Produisez maintenant l'artefact JSON et comptez proprement, par cible et par sévérité.
docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v trivy-cache:/root/.cache/trivy \ -v "$(pwd)/artefacts":/artefacts \ aquasec/trivy:0.53.0 image \ --severity HIGH,CRITICAL --format json \ --output /artefacts/trivy-image-dev-2026-08-11.json \ devsecops-lab:dev # Compter, plutot que lire 900 lignes : python3 - <<'EOF' import json, collections d = json.load(open("artefacts/trivy-image-dev-2026-08-11.json")) c = collections.Counter() for r in d.get("Results", []): for v in r.get("Vulnerabilities", []) or []: c[(r.get("Target"), v["Severity"])] += 1 for (cible, sev), n in sorted(c.items()): print(f"{n:5d} {sev:9s} {cible}") EOF - Regardez la répartition. L'écrasante majorité des lignes porte sur la cible Debian, pas sur vos paquets Python — que le M2 a déjà corrigés. Le problème n'est pas dans votre code : il est dans le choix de l'image de base. C'est ce que le TP 4 va traiter.
- Passez au second moteur.
trivy configne regarde pas les paquets installés mais les fichiers de configuration : votreDockerfile, et plus tard vos manifestes Kubernetes.docker run --rm \ -v "$(pwd)":/src \ -v trivy-cache:/root/.cache/trivy \ aquasec/trivy:0.53.0 config --severity HIGH,CRITICAL /src - Vous devez y lire au moins deux constats : l'image tourne en
root(aucune instructionUSER) et l'utilisateur n'est pas défini. Ce sont des défauts de conception, pas des vulnérabilités de paquets — et aucune mise à jour ne les corrigera. - Troisième moteur, plus court : la recherche de secrets dans les couches de l'image. Vous savez déjà ce qu'il va trouver.
docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v trivy-cache:/root/.cache/trivy \ aquasec/trivy:0.53.0 image \ --scanners secret --format table devsecops-lab:dev - Consignez la mesure de référence. C'est la ligne « avant » de votre tableau de durcissement.
cat >> journal-incidents.md <<'EOF' ## 2026-08-11 — Mesure avant durcissement (M3 J1) - Image : devsecops-lab:dev - Commande : trivy image --severity HIGH,CRITICAL devsecops-lab:dev - Base Trivy : <version affichee au demarrage> - Systeme (debian) : <n> CRITICAL / <n> HIGH - Python : <n> CRITICAL / <n> HIGH - trivy config : <n> HIGH (root, USER absent) - Secrets : <n> (dont .env embarque) EOF
- Pourquoi vos dépendances Python, corrigées au M2, ne suffisent-elles pas à faire baisser le total du scan d'image ?
trivy imageettrivy configne trouvent pas les mêmes choses. Formulez en une phrase ce que chacun cherche.- Vous avez donné à Trivy l'accès au socket Docker. Comment justifieriez-vous cette décision devant un auditeur, et à quelle condition deviendrait-elle inacceptable ?
Vous montez ici /var/run/docker.sock dans le conteneur Trivy. Vous venez de démontrer au TP 1 que cela revient à lui donner le root de l'hôte. C'est acceptable parce que vous choisissez l'outil, la version est épinglée, le conteneur est éphémère et la machine est la vôtre — pas parce que c'est anodin. Sachez le justifier : la question tombe en entretien.
Sous Docker Desktop, si le socket n'existe pas à ce chemin, remplacez le montage par -v $HOME/.docker/run/docker.sock:/var/run/docker.sock. Le symptôme est sans ambiguïté : Cannot connect to the Docker daemon.
Enfin, ne notez jamais un nombre seul. « 47 HIGH » ne veut rien dire dans six semaines : notez la commande, la date, le tag et la version de la base de vulnérabilités affichée par Trivy au démarrage.
Intention — obtenir la mesure avant durcissement, celle à laquelle le TP 4 se comparera. Et surtout : faire attribuer chaque résultat à sa source (OS, bibliothèques Python, mauvaise configuration du Dockerfile, secrets), sans quoi il corrigera au hasard.
Ce qui coince
- Le premier lancement télécharge la base : deux à quatre minutes sans sortie. Il interrompt. Le volume
trivy-cacheévite que cela recommence — insister pour qu'il le garde. - Socket introuvable sous Docker Desktop. Symptôme net, cause banale ; ne pas laisser chercher dix minutes.
- Il lit les 900 lignes de la sortie table et se noie. Le script de comptage est là pour ça : on compte d'abord, on lit ensuite.
- Confusion entre
trivy image(paquets installés) ettrivy config(mauvaises configurations dans les fichiers). Deux moteurs, deux natures de résultat.
Questions à anticiper
- « Faut-il corriger les 40 ? » — non, et c'est la bonne question. La réponse du jour est structurelle : changer d'image de base en supprime l'essentiel d'un coup. Ne pas donner
--ignore-unfixedmaintenant, il arrive au J3 avec le gate. - « Pourquoi Trivy trouve-t-il des secrets ? » — il a un scanner dédié ; c'est un second avis derrière Gitleaks, pas un remplaçant.
Ce qu'il faut exiger — un tableau de comptage par sévérité et par cible (le système d'un côté, requirements.txt/Python de l'autre), et le fichier JSON écrit dans artefacts/ au format de nommage du référentiel.
Réduire la surface : image de base, multi-stage, non-root
Jour 1 · L'image : ce qu'elle contient vraiment · 25 min
Le scan que vous venez de faire a produit un chiffre. Il faut maintenant le faire baisser, et surtout savoir quel geste le fait baisser.
L'ordre de priorité, du plus payant au moins payant
| Geste | Ce qu'il supprime | Effet typique sur le scan |
|---|---|---|
1. Changer d'image de basepython:3.12 → python:3.12-slim | Plusieurs centaines de paquets Debian jamais appelés : compilateurs, en-têtes, outils | La grande majorité des lignes disparaît d'un coup |
| 2. Séparer build et runtime multi-stage | Tout l'outillage de compilation, absent de l'image finale | Ce qui reste après le point 1, quand il faut compiler |
3. Ne pas tourner en rootUSER 10001 | Rien dans le scan — mais tout dans l'impact d'une compromission | Zéro ligne en moins, et c'est le geste le plus important |
4. Ne pas copier ce qui ne sert pas.dockerignore | .env, .git, artefacts, rapports | Zéro CVE en moins, un secret et un historique en moins |
Et ils sont pourtant les plus importants. Un tableau de bord qui n'affiche que le nombre de CVE récompense les deux premiers gestes et ignore les deux autres. Sachez le dire : c'est une critique classique — et fondée — des indicateurs de sécurité d'image.
Le multi-stage en une phrase
Un seul Dockerfile, deux instructions FROM. La première étape
installe, compile, produit. La seconde repart d'une base propre et ne recopie que le
résultat. Tout ce qui a servi à construire — pip et ses caches,
les en-têtes, le compilateur — reste dans une étape que l'image finale ne contient pas.
# build
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# runtime
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=build /install /usr/local
COPY app.py .
USER 10001
EXPOSE 5000
HEALTHCHECK CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:5000/')" || exit 1
CMD ["python", "app.py"]Le détail qui casse demain si on le rate
USER 10001, et non USER app. Les deux fonctionnent sous Docker.
Mais Kubernetes, quand vous lui demanderez runAsNonRoot: true, ne sait pas résoudre
un nom d'utilisateur : il lit l'USER de l'image, et s'il n'y trouve pas un
identifiant numérique, il refuse de démarrer le conteneur en invoquant une
impossibilité de vérifier qu'il n'est pas root. Le message est déroutant, la cause est ici.
Distroless (gcr.io/distroless/python3) va plus loin : ni shell, ni gestionnaire de paquets, ni ls. La surface est minimale, et le diagnostic aussi — kubectl exec … -- sh ne fonctionne plus. Pour un parcours où l'on passe la journée de demain à ouvrir des shells dans des pods, ce serait un mauvais arbitrage. Citez-le, expliquez le compromis, restez sur python:3.12-slim.
Même logique pour Alpine : plus petit, mais musl au lieu de glibc, et des paquets Python compilés qui refusent parfois de s'installer.
Et le reste du durcissement
.dockerignore— il agit avant leCOPY, sur ce que Docker envoie au moteur de construction. C'est la seule manière d'empêcher un fichier d'entrer ; le supprimer après ne le retire pas de la couche, vous l'avez vérifié.HEALTHCHECK— utile sous Docker, ignoré par Kubernetes, qui a ses propres sondes. On le garde pour l'usage local ; demain, il faudra unereadinessProbe.- Épingler par digest —
FROM python:3.12-slim@sha256:…. À faire en production ; ici on garde le tag pour que vos reconstructions suivent les correctifs, et on le documente comme une dette assumée.
Le réflexe à prendre est un ordre de priorité, pas une liste de bonnes pratiques. Dans cet ordre : changer d'image de base (c'est ce qui supprime le plus de lignes de scan), séparer build et runtime, ne pas tourner en root, ne pas copier ce qui ne sert pas. Un durcissement qui commence par le USER et garde python:3.12 complet fait du bruit et ne change presque rien au rapport.
Intention — donner l'ordre de priorité avant le TP 4, pour qu'il mesure un gain attribuable plutôt qu'un résultat global. Et poser la raison de l'uid numérique, qui reviendra demain quand PSA exigera runAsNonRoot.
Ce qui coince
- «
USER appsuffit. » Non : Kubernetes ne sait pas résoudre un nom d'utilisateur, il compare un uid.runAsNonRoot: trueéchoue sur une image dont l'USERest un nom non résolu en uid connu. C'est demain que ça casse, c'est aujourd'hui qu'on l'évite. - Multi-stage compris comme « deux Dockerfile ». Signe : il crée deux fichiers. Un seul fichier, deux
FROM. - Distroless présenté comme la solution : sans shell, plus de
kubectl exec … sh, et le diagnostic de demain devient pénible. On le cite, on ne l'adopte pas dans ce parcours — le dire explicitement.
Questions à anticiper
- « Alpine plutôt que slim ? » — plus petit, mais musl au lieu de glibc, et des surprises sur les roues Python compilées. Le parcours reste sur
python:3.12-slim, et c'est un arbitrage à savoir défendre. - « Le port 5000 : faut-il
CAP_NET_BIND_SERVICE? » — non, 5000 est au-dessus de 1024. Bonne occasion de dire pourquoi les images durcies évitent le port 80.
Ce qu'il faut exiger — qu'il annonce, avant de construire, quel geste il attend le plus payant et pourquoi. La prédiction se vérifie au TP 4 en dix minutes.
TP 4 — Réécrire le Dockerfile, fermer VULN-09, mesurer le gain
Jour 1 · L'image : ce qu'elle contient vraiment · 1 h 15
Produire devsecops-lab:hardened, prouver le gain par un second scan, et corriger la dernière vulnérabilité du fil rouge qui relève de ce module.
main est protégée : vous travaillez sur fix/vuln-09-debug et vous fusionnez par une pull request. Le pipeline doit rester vert — il ne connaît pas encore le scan d'image, vous l'ajouterez jeudi.
- Créez la branche de correction.
cd ~/devsecops-lab git switch -c fix/vuln-09-debug - Écrivez le
.dockerignore. Il agit avant leCOPY: c'est la seule façon d'empêcher un fichier d'entrer dans une couche..git .env artefacts/ rapports/ __pycache__/ *.pyc k8s/ - Remplacez le contenu de
Dockerfilepar la version multi-stage. Un seul fichier, deuxFROM. Notez l'USERnumérique : Kubernetes en aura besoin demain.# build FROM python:3.12-slim AS build WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # runtime FROM python:3.12-slim RUN useradd --create-home --uid 10001 app WORKDIR /app COPY --from=build /install /usr/local COPY app.py . USER 10001 EXPOSE 5000 HEALTHCHECK CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:5000/')" || exit 1 CMD ["python", "app.py"] - Construisez, puis lancez. Cela va échouer — c'est attendu, lisez l'erreur avant de continuer.
docker build -t devsecops-lab:hardened . docker run --rm -p 5002:5000 devsecops-lab:hardened - Vous lisez
sqlite3.OperationalError: unable to open database file. Diagnostic :WORKDIR /appa créé le répertoire au nom deroot; l'utilisateur 10001 n'a pas le droit d'y créerlab.db. C'est le prix normal du passage en non-root, et il se paie une fois. Ne repassez pas enroot: donnez la propriété du répertoire. - Ajoutez la ligne manquante dans l'étape runtime, juste avant
USER, puis reconstruisez.# runtime — la ligne qui manquait, juste avant USER COPY app.py . RUN chown -R 10001:10001 /app USER 10001 - Reconstruisez et vérifiez que l'application répond.
docker build -t devsecops-lab:hardened . docker run -d --name lab-hard --network dso-net -p 5002:5000 devsecops-lab:hardened sleep 2 && curl -s http://localhost:5002/ ; echo - Fermez maintenant
VULN-09dans le code lui-même. Le mode debug de Flask expose une console d'exécution : il ne doit plus s'activer que sur demande explicite.# app.py, tout en bas — remplacer la ligne app.run(...) if __name__ == "__main__": if not os.path.exists(DB): init_db() # VULN-09 corrigee : le mode debug ne s'active plus que sur demande explicite. app.run(host="0.0.0.0", port=5000, debug=os.environ.get("FLASK_DEBUG") == "1") - Reconstruisez et vérifiez les quatre propriétés de l'image durcie : utilisateur non root, uid numérique, absence de
.env, absence de.git.docker images devsecops-lab --format 'table {{.Tag}}\t{{.Size}}' docker run --rm devsecops-lab:hardened id docker run --rm devsecops-lab:hardened ls -a /app docker run --rm devsecops-lab:hardened sh -c 'ls -a /app/.env 2>&1 || true' - Mesurez. Les deux scans doivent tourner dans la même session, avec la même base de vulnérabilités — sinon la comparaison n'a aucune valeur. Écrivez d'abord le petit compteur, une fois pour toutes.
cat > compter.py <<'EOF' import sys, json, collections d = json.load(sys.stdin) c = collections.Counter() for r in d.get("Results", []): for v in r.get("Vulnerabilities") or []: c[(r.get("Class"), v["Severity"])] += 1 for k, n in sorted(c.items()): print(f"{n:5d} {k[1]:9s} {k[0]}") EOF - Lancez les deux scans à la suite et comparez les deux blocs.
for TAG in dev hardened; do echo "===== devsecops-lab:$TAG =====" docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v trivy-cache:/root/.cache/trivy \ aquasec/trivy:0.53.0 image --quiet \ --severity HIGH,CRITICAL --format json "devsecops-lab:$TAG" \ | python3 compter.py done rm compter.py - Relancez
trivy config: les constats « tourne en root » et « USER non défini » doivent avoir disparu.docker run --rm \ -v "$(pwd)":/src \ -v trivy-cache:/root/.cache/trivy \ aquasec/trivy:0.53.0 config --severity HIGH,CRITICAL /src - Committez, signé, avec un message qui dit ce qui fonctionne, puis ouvrez la pull request.
git add Dockerfile .dockerignore app.py journal-incidents.md git commit -S -m "fix(VULN-09): image multi-stage non-root, debug pilote par l environnement" git push -u origin fix/vuln-09-debug gh pr create --fill # ou l interface web
- Quel geste, parmi les quatre du tableau de priorité, a fait baisser le plus de lignes dans le rapport ? Lequel a le plus réduit l'impact d'une compromission ? Sont-ce les mêmes ?
- L'erreur SQLite était prévisible en relisant le
Dockerfile. Quelle question auriez-vous pu vous poser avant de lancer le conteneur ? - Votre image ne contient plus
requirements.txt. Quelle conséquence sur un scan de composition (SCA) fait plus tard par un tiers qui ne dispose que de l'image ?
Le passage en non-root casse quelque chose à tous les coups, la première fois. Ici, c'est sqlite3.OperationalError: unable to open database file : /app appartient à root, l'utilisateur 10001 ne peut pas y créer lab.db. Ne contournez pas en repassant root — c'est exactement le réflexe à ne pas prendre. Une ligne de chown règle le cas, et le diagnostic vaut le détour.
Deuxième point : reconstruisez :dev et :hardened le même jour, avec la même base Trivy. Comparer un scan d'hier à un scan d'aujourd'hui, c'est comparer deux bases de vulnérabilités différentes — et le gain que vous annoncerez sera faux.
Intention — le geste central du module : produire l'image que le cluster de demain déploiera, et clore VULN-09 pour de bon (dans le Dockerfile et dans app.py, pas seulement en documentation).
Ce qui coince
- L'échec SQLite à l'étape 4. C'est volontaire dans le déroulé : le
Dockerfiledu référentiel n'a pas lechown, et découvrir pourquoi vaut mieux que le recopier. S'il repasseUSER root, arrêter tout de suite. COPY app.py .seul : plus derequirements.txtdans l'image finale. Il s'en inquiète. C'est voulu — les paquets sont dans/usr/local, le fichier n'a plus d'usage à l'exécution.VULN-09traité à moitié : Dockerfile durci maisdebug=Truetoujours en dur dansapp.py. Vérifier le fichier, pas seulement l'image.- Comparaison faite avec le scan de la veille. Exiger les deux scans dans la même demi-heure.
Questions à anticiper
- « Pourquoi
--prefix=/installpuisCOPY /install /usr/local? » — c'est le mécanisme du multi-stage pour Python : on déplace l'arborescence installée sans emporter pip ni ses caches. - « Le HEALTHCHECK sert-il à quelque chose ? » — sous Docker oui, sous Kubernetes non. Annoncer la
readinessProbede demain.
Ce qu'il faut exiger — quatre preuves : docker run … id qui affiche uid=10001, ls -a /app sans .env ni .git, les deux comptages Trivy du même jour, et grep -n debug app.py qui montre la lecture d'environnement. Un commit signé sur fix/vuln-09-debug.
TP 5 — Consigner la réduction de surface
Jour 1 · L'image : ce qu'elle contient vraiment · 45 min
Écrire la première partie du dossier de durcissement : un avant/après chiffré, daté et rejouable.
- Créez le rapport et son squelette. Il grandira jeudi avec les parties Kubernetes.
mkdir -p rapports cat > rapports/durcissement-conteneurs.md <<'EOF' # Durcissement — devsecops-lab Auteur : Elie Zaraket · POEI 2026 · module M3 Perimetre : image devsecops-lab, cluster local kind dso-lab. localhost uniquement. ## 1. Image — etat des lieux et correctifs ## 2. Cluster — ce que chaque garde-fou empeche ## 3. Secrets ## 4. Audit CIS et limites de la demonstration ## 5. Ce qui n a pas ete fait, et pourquoi EOF - Rédigez la section 1. Elle contient quatre choses et rien d'autre : les conditions de mesure, le tableau avant/après, la liste des gestes avec ce que chacun a réellement changé, et la dette assumée.
- Les conditions de mesure, en tête de section — sans elles, aucun chiffre n'est opposable.
### Conditions de mesure - Date : 2026-08-11 - Outil : aquasec/trivy:0.53.0 - Base de vulns : <version affichee par Trivy> - Commande : trivy image --severity HIGH,CRITICAL --format json <tag> - Images : devsecops-lab:dev et devsecops-lab:hardened, construites le meme jour - Le tableau avant/après. Remplissez-le avec vos nombres, pas avec un exemple.
| Mesure | :dev | :hardened | |---------------------------------|------|-----------| | Taille de l image | | | | CRITICAL (paquets systeme) | | | | HIGH (paquets systeme) | | | | CRITICAL + HIGH (Python) | | | | Constats trivy config | | | | Secrets detectes dans l image | | | | Utilisateur d execution | root | 10001 | - Les quatre gestes, avec pour chacun ce qu'il a changé — y compris quand la réponse est « rien dans le compteur ».
### Gestes appliques 1. Image de base python:3.12 -> python:3.12-slim Effet mesure : ... CVE en moins. C est le geste le plus payant sur le compteur. 2. Construction multi-stage (outillage pip absent du runtime) Effet mesure : ... 3. USER 10001 (identifiant numerique, requis par runAsNonRoot cote Kubernetes) Effet mesure : AUCUNE CVE en moins. Reduit l impact d une compromission, pas le nombre de vulnerabilites. Le compteur ne mesure pas cela. 4. .dockerignore (.env, .git, artefacts/, rapports/) Effet mesure : AUCUNE CVE en moins. Retire un secret et l historique Git complet de l image, ce qu aucune mise a jour de paquet n aurait fait. - La dette assumée. Un dossier de durcissement qui ne dit pas ce qu'il laisse ouvert n'est pas crédible.
### Dette assumee - Image de base epinglee par tag et non par digest : les reconstructions suivent les correctifs, au prix de la reproductibilite exacte. A revoir avant mise en production : FROM python:3.12-slim@sha256:... - Vulnerabilites residuelles sans correctif disponible (--ignore-unfixed) : a traiter au jour 3 avec le gate de pipeline. - readOnlyRootFilesystem non applique au niveau du conteneur Docker : sera pose au niveau Kubernetes, jour 2. - Complétez le journal, puis committez le rapport sur votre branche.
git add rapports/durcissement-conteneurs.md journal-incidents.md git commit -S -m "docs(M3): dossier de durcissement, section image" git push
- Un responsable lit votre tableau et conclut « le risque a baissé de 87 % ». Que lui répondez-vous ?
- Vous devez choisir un seul indicateur pour suivre la qualité de vos images dans le temps. Lequel, et quelle est sa faiblesse ?
Le piège de ce genre de document est la phrase « on est passé de 47 à 6 ». Elle ne vaut rien sans la commande, la date, le tag et la version de la base de vulnérabilités. Un lecteur doit pouvoir rejouer votre mesure et retrouver vos chiffres — ou constater qu'ils ont changé, et savoir pourquoi.
Écrivez aussi la ligne qui n'a pas bougé : le passage en non-root n'a supprimé aucune CVE. C'est ce qui montre que vous avez compris ce que le compteur mesure.
Intention — transformer une manipulation en livrable opposable, et faire écrire la nuance que le chiffre ne porte pas. C'est le document que le PFR reprendra dans audit-devsecops-lab.md ; il est écrit une fois, ici.
Ce qui coince
- Le tableau recopié du cours au lieu de porter ses propres mesures. Demander les deux nombres à l'oral avant d'ouvrir le fichier.
- Les gestes 3 et 4 passés sous silence parce qu'ils ne changent pas le compteur. C'est exactement ce qu'il faut exiger d'écrire.
- La dette assumée (tag et non digest) non documentée. Un durcissement honnête dit ce qu'il n'a pas fait.
Questions à anticiper
- « Faut-il versionner le JSON de Trivy ? » — non,
artefacts/est ignoré ; c'est le rapport qui se versionne, et il cite le nom du fichier. - « Combien de pages ? » — deux, tableau compris. Un dossier de durcissement n'est pas un mémoire.
Ce qu'il faut exiger — que chaque nombre du tableau soit accompagné de la commande qui le produit, et que la section « ce qui n'a pas été fait » existe et ne soit pas vide.
M3 · J1 — Image durcie : non-root, multi-stage, gain mesuré
Jour 1 · L'image : ce qu'elle contient vraiment · 30 min
Votre encadrant ne vous demandera pas de raconter : il tapera lui-même docker run --rm devsecops-lab:hardened id. Ayez un terminal prêt, à la racine du dépôt.
Préparez aussi une phrase pour la question qui vient toujours : quel geste a le plus réduit le nombre de CVE, et quel geste a le plus réduit le risque ? Ce ne sont pas les mêmes, et savoir le dire vaut mieux que le meilleur des tableaux.
Intention — verrouiller l'image avant qu'elle entre dans un cluster. Une image mal construite déployée demain produit des symptômes Kubernetes (CrashLoopBackOff, CreateContainerConfigError) dont la cause est ici, et le diagnostic coûte alors trois fois plus.
Ce qu'il faut exiger — quatre gestes, faits par lui devant vous :
docker run --rm devsecops-lab:hardened id→uid=10001. Un nom d'utilisateur à la place du chiffre est un échec du critère, même si Docker l'accepte : demain,runAsNonRootle refusera.docker run --rm devsecops-lab:hardened ls -a /app→ ni.envni.git.grep -n "debug" app.py→ lecture d'environnement, pasTrue.- Les deux comptages Trivy, annoncés de mémoire puis vérifiés.
Ce qui coince
- Le critère bâclé : la section « ce qui n'a pas été fait » du rapport. Souvent vide. C'est pourtant ce qui distingue un audit d'un communiqué.
VULN-09corrigée dans le Dockerfile mais pas dansapp.py. Ouvrir le fichier.- Comparaison de scans faits à deux jours d'intervalle. Demander les horodatages.
Questions à anticiper
- « Je finis le rapport demain matin ? » — non : demain est la journée la plus dense du module, et le TP 10 suppose la section 1 écrite.
Ne pas valider si — l'USER est un nom, ou si l'image n'a pas été relancée après le chown : le pod de demain ne démarrera pas et la matinée y passera.
Kubernetes : trois portes, trois questions différentes
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 25 min
Vous allez monter un cluster en une commande. Avant cela, il faut savoir qui décide quoi, parce que la journée entière consiste à provoquer trois refus différents et à ne pas les confondre.
Le chemin d'une demande
Tout passe par un seul composant : l'API server. Il n'y a pas d'autre porte d'entrée. Une demande le traverse en trois étapes, et peut être rejetée à chacune.
| Porte | Question tranchée | Refus typique |
|---|---|---|
| Authentification | Qui êtes-vous ? | Unauthorized — certificat ou jeton invalide |
| Autorisation — RBAC | Avez-vous le droit de faire cette action sur cet objet ? | Error from server (Forbidden): … is forbidden: User … cannot get resource "secrets" |
| Admission — PSA | L'objet que vous écrivez est-il licite ici ? | Error from server (Forbidden): … violates PodSecurity "restricted:latest" |
La ressemblance des deux derniers messages est un piège quotidien : les deux disent
Forbidden. Ce qui les distingue est la suite de la phrase — cannot get
resource pour RBAC, violates PodSecurity pour l'admission. Prenez l'habitude de
lire au-delà du premier mot.
Ce qui n'est pas dans cette chaîne
La NetworkPolicy n'y figure pas, et c'est important. Elle ne juge aucune demande à l'API : elle s'applique au trafic réseau, une fois les pods en marche. Question tranchée : ce pod a-t-il le droit de parler à celui-là ? Aucun message d'erreur — juste une connexion qui n'aboutit pas.
Une NetworkPolicy n'est qu'un objet dans l'API. C'est le plugin réseau (CNI) qui l'applique — ou pas. Le CNI par défaut de kind, kindnet, ne l'applique pas : votre politique default-deny sera acceptée, affichée par kubectl get netpol, et ne bloquera rien du tout.
C'est exactement le genre de faux positif d'audit qui coûte cher : la politique est là, la protection n'y est pas. C'est pour cela que le cluster du parcours désactive kindnet et installe Calico.
Le vocabulaire minimal du jour
| Objet | À quoi il sert ici |
|---|---|
| Namespace | Le périmètre dans lequel s'appliquent Role, PSA et NetworkPolicy. Sans eux, ce n'est qu'un préfixe de nom. |
| ServiceAccount | L'identité d'un pod vis-à-vis de l'API. Chaque pod en a une, même quand on ne l'a pas demandée. |
| Role / RoleBinding | Un ensemble de droits dans un namespace, et l'attribution de cet ensemble à une identité. |
| securityContext | Les privilèges demandés par le conteneur. C'est ce que PSA lit pour accepter ou refuser. |
| NetworkPolicy | Une liste blanche de flux. Dès qu'un pod est visé par une politique, tout ce qui n'y figure pas est refusé. |
Fidèlement : RBAC, PodSecurityAdmission, NetworkPolicy — c'est l'apiserver amont, il se comporte comme en production.
Pas du tout : le chiffrement des secrets au repos par un KMS, l'audit complet de l'apiserver, un vrai réseau multi-machines, l'IAM d'un fournisseur cloud. On les traitera en leçon, jeudi, sans prétendre les avoir montés.
Intention — donner la carte avant le terrain. Trois refus possibles, trois endroits différents, trois messages d'erreur différents. Sans cette leçon, il passera la journée à confondre un 403 Forbidden de RBAC avec un refus d'admission de PSA, et cherchera le correctif au mauvais endroit.
Ce qui coince
- La NetworkPolicy rangée dans la même chaîne que RBAC et PSA. Elle n'y est pas : elle s'applique au trafic, après que le pod existe. Signe : « pourquoi ma NetworkPolicy n'empêche-t-elle pas le pod de démarrer ? ».
- Le kubelet oublié. Il est pourtant l'exécutant : PSA refuse à l'écriture, le kubelet refuse au démarrage (cas de
runAsNonRootsur une image dont l'USERest un nom). Deux refus, deux moments. - Le namespace pris pour une frontière de sécurité. Ce n'en est une que si RBAC, PSA et NetworkPolicy y sont posés — c'est tout le programme de la journée.
Questions à anticiper
- « Pourquoi installer Calico plutôt que garder le CNI par défaut ? » — parce que
kindnetn'applique pas les NetworkPolicy : l'API les accepte, rien ne bloque. Le dire maintenant, pas au moment du TP 9. - « Un cluster local, c'est représentatif ? » — pour RBAC, PSA et NetworkPolicy, oui : c'est l'apiserver amont. Pour le chiffrement des secrets et l'audit, non — et c'est annoncé au jour 3.
Ce qu'il faut exiger — qu'il associe, sans la page, chaque garde-fou à la question qu'il tranche et au message d'erreur qu'il produit.
TP 6 — Monter dso-lab : kind, Calico, et l'image durcie dedans
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 1 h 15
Obtenir un cluster à deux nœuds qui applique réellement les NetworkPolicy, et y faire tourner devsecops-lab:hardened.
kind v0.23.0 et kubectl installés au module d'accueil (kind version, kubectl version --client). Docker doit disposer d'au moins 6 Go de mémoire : deux nœuds plus Calico ne tiennent pas dans 4 Go.
Comptez 5 à 8 minutes de téléchargement au premier montage. Le cluster créé ici sert jusqu'à jeudi soir : ne le détruisez pas.
- Créez le dossier des manifestes et le fichier de configuration du cluster, à la racine de
devsecops-lab.cd ~/devsecops-lab mkdir -p k8s # --- kind-config.yaml, a la racine du depot --- - Voici le contenu de
kind-config.yaml.disableDefaultCNI: trueest la ligne décisive : elle retirekindnet, qui n'applique pas les NetworkPolicy.kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 name: dso-lab networking: disableDefaultCNI: true # on remplace kindnet par Calico podSubnet: "192.168.0.0/16" nodes: - role: control-plane - role: worker - Créez le cluster. Les nœuds vont apparaître
NotReady: c'est attendu, il n'y a pas encore de réseau.kind create cluster --config kind-config.yaml --image kindest/node:v1.30.0 # Les noeuds restent NotReady tant qu'aucun CNI n'est installe : c'est normal. kubectl get nodes - Installez Calico, puis attendez qu'il soit prêt. C'est l'étape longue — deux à cinq minutes selon votre connexion. Ne l'interrompez pas.
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml kubectl -n kube-system rollout status ds/calico-node --timeout=300s kubectl wait --for=condition=Ready nodes --all --timeout=300s kubectl get nodes - Les deux nœuds doivent maintenant afficher
Ready. Si ce n'est pas le cas après cinq minutes, regardezkubectl -n kube-system get pods: un podcalico-nodeenImagePullBackOffest un problème de réseau sortant, pas de configuration. - Chargez votre image durcie dans les nœuds. Votre poste et les nœuds kind ont des magasins d'images séparés : sans cette commande, le kubelet ne la trouvera pas.
kind load docker-image devsecops-lab:hardened --name dso-lab # Verification : l'image doit apparaitre dans le noeud, pas seulement sur votre poste. docker exec dso-lab-worker crictl images | grep devsecops-lab - Écrivez
k8s/deployment.yaml. Deux objets : le Deployment et le Service qui lui donne un nom stable dans le cluster.# k8s/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: devsecops-lab namespace: dso spec: replicas: 1 selector: matchLabels: app: devsecops-lab template: metadata: labels: app: devsecops-lab spec: containers: - name: app image: devsecops-lab:hardened imagePullPolicy: IfNotPresent # sinon kubelet va la chercher sur Docker Hub ports: - containerPort: 5000 --- apiVersion: v1 kind: Service metadata: name: devsecops-lab namespace: dso spec: selector: app: devsecops-lab ports: - port: 5000 targetPort: 5000 - Créez le namespace, déployez, et vérifiez que l'application répond depuis votre poste.
kubectl create namespace dso kubectl apply -f k8s/deployment.yaml kubectl -n dso rollout status deploy/devsecops-lab --timeout=120s kubectl -n dso get pod -o wide # Depuis votre poste, par un tunnel : kubectl -n dso port-forward svc/devsecops-lab 5001:5000 & curl -s http://localhost:5001/ ; echo - Vous devez lire
devsecops-lab — cible d'entrainement locale. Si le pod est enCrashLoopBackOff, regardez d'abordkubectl -n dso logs deploy/devsecops-lab: une erreur SQLite signifie que vous avez chargé une image construite avant lechownd'hier. - Arrêtez le port-forward et versionnez ce que vous venez d'écrire.
kill %1 2>/dev/null git add kind-config.yaml k8s/deployment.yaml git commit -S -m "feat(k8s): cluster kind dso-lab avec Calico, deploiement de l image durcie"
disableDefaultCNI: truerend le cluster inutilisable pendant plusieurs minutes. Qu'est-ce que cela apprend sur le rôle du CNI ?- Votre poste et les nœuds kind ont des magasins d'images distincts. En quoi cette séparation ressemble-t-elle à la situation réelle d'un cluster et d'un registre ?
- Le Service porte le nom
devsecops-lab. À quoi correspond, dans le DNS du cluster, le nom completdevsecops-lab.dso.svc.cluster.local?
Deux pièges, et un seul vous fera perdre du temps si vous ne les connaissez pas.
Un. Après kind create cluster, les nœuds restent NotReady et les pods CoreDNS en Pending. Ce n'est pas une panne : disableDefaultCNI: true fait qu'aucun réseau n'est installé. Tout se débloque à la seconde où Calico est prêt.
Deux. imagePullPolicy: IfNotPresent n'est pas décoratif. Sans cette ligne, une image dont le tag n'est pas latest est certes cherchée localement — mais dès que vous retaguerez ou publierez, le kubelet ira sur Docker Hub, n'y trouvera rien et vous laissera un ErrImagePull. Et kind load docker-image n'est pas optionnel : votre poste et les nœuds kind ont des magasins d'images séparés.
Intention — poser le cluster définitif des deux jours, avec le CNI qui rendra le TP 9 démonstratif. Ce n'est pas un TP sur Kubernetes : c'est la pose du décor, et l'élimination des deux pannes qui mangent une demi-journée.
Ce qui coince
NotReadylu comme un échec. Il détruit le cluster et recommence, parfois deux fois. Le prévenir avant qu'il tape la commande.- Calico long à démarrer (2 à 5 min, plusieurs images à tirer).
rollout statusest là pour attendre ; s'il l'interrompt, il verra des podsPendinget conclura à un échec. kind loadoublié →ErrImagePull. Le message est clair, la cause est contre-intuitive : deux magasins d'images distincts.- Sur machine à 8 Go, deux nœuds + Calico serrent. Si Docker Desktop est limité à 4 Go, augmenter avant, pas pendant.
Questions à anticiper
- « Pourquoi
192.168.0.0/16? » — c'est l'IPPoolpar défaut de Calico ; le faire coïncider évite une configuration supplémentaire. - « Pourquoi deux nœuds ? » — pour que le TP 9 montre un flux qui traverse réellement le réseau, et pas seulement une boucle locale.
- « kind tourne dans Docker, donc dans un conteneur privilégié ? » — oui. Bonne occasion de refermer la leçon d'hier.
Ce qu'il faut exiger — kubectl get nodes avec deux Ready, kubectl -n kube-system get ds calico-node avec 2/2 prêts, et un curl qui répond à travers le port-forward. Pas « le cluster est monté ».
RBAC : ce qu'un pod compromis peut demander à l'API
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 25 min
Un pod n'est pas un simple processus : il a une identité auprès de l'API du cluster, et par défaut, un jeton d'authentification est déposé dans son système de fichiers, à un chemin fixe et connu de tous :
/var/run/secrets/kubernetes.io/serviceaccount/tokenConséquence directe, et c'est tout l'enjeu : toute exécution de code dans votre pod
donne accès à ce jeton. Une injection de commande, une désérialisation, une bibliothèque
compromise — vous en avez huit exemples dans devsecops-lab. Ce que l'attaquant peut
faire ensuite ne dépend plus de votre code : cela dépend uniquement de ce que RBAC accorde
à ce ServiceAccount.
Les quatre objets, et rien de plus
| Objet | Portée | Ce qu'il déclare |
|---|---|---|
| ServiceAccount | Namespace | Une identité, attribuée à des pods |
| Role | Namespace | Une liste de droits : ressources × verbes |
| RoleBinding | Namespace | Qui obtient quel Role |
| ClusterRole / ClusterRoleBinding | Cluster entier | Les mêmes, sans frontière de namespace — à manier avec précaution |
Un ClusterRole lié par un RoleBinding ne s'applique que dans le namespace du RoleBinding. C'est un usage courant et légitime : on écrit le rôle une fois, on l'attribue localement. Ce qui donne des droits sur tout le cluster, c'est le ClusterRoleBinding.
RBAC n'interdit pas — il accorde
Il n'existe aucune règle deny. Tout est refusé par défaut, et une règle ne fait
qu'ajouter. On ne « bloque » donc jamais un accès : on s'abstient de l'accorder, ou on retire la
règle qui l'accordait. Cela change la façon de relire un rbac.yaml : ce n'est pas
une liste de garde-fous, c'est la totalité de ce qui est possible.
Les trois motifs à reconnaître d'un coup d'œil
| Écriture | Ce qu'elle accorde réellement | Verdict |
|---|---|---|
resources: ["*"]verbs: ["*"] | Tout, y compris lire et modifier les Secrets du namespace | À refuser, même « juste pour la démonstration » |
resources: ["pods"]verbs: ["get", "list"] | Lire la liste des pods, rien d'autre | Acceptable — c'est un besoin qui s'énonce |
resources: ["secrets"]resourceNames: ["lab-db"] | Lire un secret précis, nommé | Acceptable quand le besoin est réel |
La commande qui répond sans rien exécuter
kubectl auth can-i get secrets \
--as=system:serviceaccount:dso:lab-sa -n dso
kubectl auth can-i --list \
--as=system:serviceaccount:dso:lab-sa -n dsoLa seconde forme donne la liste complète de ce que l'identité peut faire. C'est l'outil de revue : on la lit à voix haute, et chaque ligne doit correspondre à un besoin nommé.
Si votre application ne parle jamais à l'API Kubernetes — c'est le cas de devsecops-lab — alors elle n'a aucune raison d'avoir un jeton monté. automountServiceAccountToken: false supprime le fichier du pod. Aucune permission à ajuster, aucun jeton à voler : le problème n'existe plus.
La question à se poser n'est jamais « ce compte a-t-il les droits dont il a besoin ? » — on finit toujours par répondre oui en élargissant. La bonne question est l'inverse : que peut faire ce compte que l'application n'a jamais demandé ? C'est la seule formulation qui conduit à retirer des droits.
Et retenez le raccourci de vérification : kubectl auth can-i <verbe> <ressource> --as=system:serviceaccount:<ns>:<nom>. Il répond yes ou no sans rien exécuter, et c'est ce que vous montrerez en point de contrôle.
Intention — faire comprendre que RBAC n'est pas une formalité de déploiement mais la limite de dégâts d'un pod compromis. Le lien avec le M5 est direct : une SSRF ou une exécution de commande dans le pod donne accès au jeton du ServiceAccount.
Ce qui coince
- Le ServiceAccount cru optionnel. Tout pod en a un —
default— et son jeton est monté par défaut. C'est le point de départ de l'exercice. resources: ["*"]jugé acceptable « parce que c'est limité au namespace ». Or les Secrets du namespace y sont, et c'est tout ce qu'il faut.- Role et ClusterRole confondus. Un ClusterRole lié par un RoleBinding ne vaut que dans le namespace : subtilité qui piège tout le monde une fois.
- Aucun verbe
denyen RBAC — c'est une liste blanche pure. Il cherche à interdire ; on n'interdit pas, on n'accorde pas.
Questions à anticiper
- «
can-iavec--as, n'importe qui peut donc l'utiliser ? » — non :--asexige le droit d'usurpation, que l'administrateur du cluster possède. Excellente question, y répondre franchement. - « Et si l'application a vraiment besoin de lire des secrets ? » — alors on nomme le secret précis dans
resourceNames, on n'ouvre pas la ressource.
Ce qu'il faut exiger — qu'il énonce ce que le jeton monté dans le pod permet de faire, et par quel chemin un attaquant y accède.
TP 7 — Ce que RBAC empêche : voler les secrets depuis un pod
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 1 h
Exploiter un ServiceAccount trop permissif depuis l'intérieur d'un pod, puis rendre la même attaque impossible.
Vous allez exploiter un compte de service trop permissif dans votre cluster, sur votre poste. C'est la seule cible autorisée. La technique est réelle et documentée — c'est l'une des premières choses que fait un attaquant après avoir obtenu l'exécution de code dans un pod.
- Écrivez
k8s/rbac.yaml, version 1 : celle qu'on rencontre dans la moitié des dépôts publics.# k8s/rbac.yaml — VERSION 1, celle qu'on trouve partout et qu'on va casser apiVersion: v1 kind: ServiceAccount metadata: name: lab-sa namespace: dso --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: lab-role namespace: dso rules: - apiGroups: [""] resources: ["*"] verbs: ["*"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: lab-rb namespace: dso subjects: - kind: ServiceAccount name: lab-sa namespace: dso roleRef: kind: Role name: lab-role apiGroup: rbac.authorization.k8s.io - Écrivez
k8s/attaquant.yaml: un pod ordinaire, avec un shell, qui porte l'identitélab-sa.# k8s/attaquant.yaml — un pod avec un shell, qui porte le compte lab-sa apiVersion: v1 kind: Pod metadata: name: attaquant namespace: dso labels: role: audit spec: serviceAccountName: lab-sa containers: - name: curl image: curlimages/curl:8.8.0 command: ["sleep", "36000"] - Créez un Secret dans le namespace — celui que l'attaque va viser — puis appliquez les deux manifestes.
kubectl -n dso create secret generic lab-secrets \ --from-literal=SECRET_KEY='s3cr3t-dev-key-ne-pas-committer-42' \ --from-literal=API_KEY='AKIAIOSFODNN7EXAMPLE' kubectl apply -f k8s/rbac.yaml kubectl apply -f k8s/attaquant.yaml kubectl -n dso wait --for=condition=Ready pod/attaquant --timeout=90s - Première vérification, sans rien exécuter : demandez à l'API ce que ce compte a le droit de faire.
kubectl auth can-i get secrets \ --as=system:serviceaccount:dso:lab-sa -n dso kubectl auth can-i delete pods \ --as=system:serviceaccount:dso:lab-sa -n dso kubectl auth can-i list secrets \ --as=system:serviceaccount:dso:lab-sa -n kube-system - Les deux premières commandes répondent
yes, la troisièmeno: le Role est limité au namespacedso. C'est la seule bonne nouvelle de cette configuration. - Passons à l'exploitation. Entrez dans le pod, lisez le jeton, et interrogez l'API comme le ferait un attaquant qui vient d'obtenir un shell.
kubectl -n dso exec -it attaquant -- sh # --- a l'interieur du pod --- SA=/var/run/secrets/kubernetes.io/serviceaccount TOKEN=$(cat $SA/token) curl -s --cacert $SA/ca.crt \ -H "Authorization: Bearer $TOKEN" \ https://kubernetes.default.svc/api/v1/namespaces/dso/secrets \ | head -c 600 ; echo - La réponse est un JSON contenant
lab-secretset ses valeurs. Vous venez d'exfiltrer les secrets du namespace depuis un conteneur applicatif, sans exploiter la moindre faille du code. Sortez du pod avecexit. - Corrigez. Version 2 de
k8s/rbac.yaml: le strict nécessaire, plus le refus de monter le jeton.# k8s/rbac.yaml — VERSION 2, moindre privilege apiVersion: v1 kind: ServiceAccount metadata: name: lab-sa namespace: dso automountServiceAccountToken: false --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: lab-role namespace: dso rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: lab-rb namespace: dso subjects: - kind: ServiceAccount name: lab-sa namespace: dso roleRef: kind: Role name: lab-role apiGroup: rbac.authorization.k8s.io - Appliquez, et recréez le pod. Ce second point est essentiel :
automountServiceAccountTokenn'agit qu'à la création du pod ; un pod déjà en marche garde son jeton.kubectl apply -f k8s/rbac.yaml kubectl -n dso delete pod attaquant --now kubectl apply -f k8s/attaquant.yaml kubectl -n dso wait --for=condition=Ready pod/attaquant --timeout=90s - Rejouez les deux vérifications. La première doit maintenant répondre
no, et la seconde montrer que le jeton n'est même plus dans le pod.kubectl auth can-i get secrets \ --as=system:serviceaccount:dso:lab-sa -n dso kubectl -n dso exec attaquant -- \ ls /var/run/secrets/kubernetes.io/serviceaccount/ \ || echo 'aucun jeton monte — l attaque n a plus de point de depart' - Consignez les deux états. Ce sont eux que vous montrerez ce soir.
cat >> journal-incidents.md <<'EOF' ## 2026-08-12 — RBAC (M3 J2) - Avant : Role resources[*] verbs[*] -> can-i get secrets = yes, et exfiltration reussie depuis le pod via le jeton du ServiceAccount - Apres : Role pods/get,list + automountServiceAccountToken: false -> can-i get secrets = no, et plus aucun jeton monte dans le pod - Piege releve : le pod doit etre RECREE, la desactivation du montage ne s applique pas a un pod deja en marche EOF git add k8s/rbac.yaml k8s/attaquant.yaml journal-incidents.md git commit -S -m "fix(k8s): RBAC de moindre privilege, jeton non monte"
- L'attaque n'a exploité aucune vulnérabilité de
devsecops-lab. Qu'est-ce que cela dit du périmètre d'un test d'intrusion sur une application conteneurisée ? automountServiceAccountToken: falsesupprime le problème plutôt que de le restreindre. Dans quels cas ne pouvez-vous pas l'utiliser ?- Votre application doit lire un unique secret nommé
lab-db. Écrivez, de mémoire, les trois lignes du Role qui l'autorisent sans ouvrir les autres.
L'ordre compte : vous montez d'abord la version large, vous réussissez l'attaque, et seulement ensuite vous restreignez. Un durcissement qu'on n'a jamais vu échouer avant ne s'ancre pas — et surtout, on ne sait pas ce qu'on a réellement fermé.
Si curl renvoie une erreur de certificat, c'est que vous avez oublié --cacert $SA/ca.crt. Résistez à -k : le fichier est précisément là pour vérifier l'API, et l'attaquant l'utiliserait aussi.
Intention — rendre RBAC tangible : le jeton lu, la requête HTTP faite à la main, les Secrets en base64 dans la réponse. Après ça, resources: ["*"] ne se réécrit plus jamais distraitement.
Ce qui coince
- Il saute l'étape offensive et applique directement la version 2. Le TP perd tout. Exiger qu'il ait vu la réponse JSON contenant
lab-secrets. -kà la place de--cacert: ça marche, et ça enterre le point sur la chaîne de confiance. Le reprendre.- Après la version 2, le pod
attaquanta toujours son ancien jeton monté :automountServiceAccountToken: falsene s'applique qu'aux pods recréés. Il faut supprimer et recréer le pod — piège classique et instructif. - Confusion entre
403(RBAC refuse) et401(jeton invalide). Deux causes, deux corrections.
Questions à anticiper
- « Pourquoi les valeurs sont-elles en base64 dans la réponse ? » — parce qu'un Secret est encodé, pas chiffré. Ne pas développer : c'est la leçon de jeudi matin.
- « Un attaquant réel aurait-il
curldans le pod ? » — pas forcément, et c'est un argument pour les images minimales. Mais Python suffit, et il est là.
Ce qu'il faut exiger — la sortie yes puis no de can-i, et la réponse 403 obtenue depuis l'intérieur du pod après la version 2. Les deux, dans cet ordre, notées dans le journal.
Quiz — RBAC, admission et réseau
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 10 min
1. kubectl auth can-i get secrets --as=system:serviceaccount:dso:lab-sa -n dso répond yes. Que faut-il relire pour comprendre pourquoi ?
2. Un namespace contient déjà trois pods qui tournent en root. Vous y posez l'étiquette pod-security.kubernetes.io/enforce=restricted. Que se passe-t-il ?
3. Vous appliquez une NetworkPolicy default-deny-ingress, kubectl get netpol la montre, et tous les flux passent toujours. Quelle est la cause la plus probable ?
4. Après une politique default-deny-egress, un curl vers un service du cluster échoue avec « could not resolve host ». Pourquoi ?
5. Un ClusterRole donnant get sur les Secrets est attribué à un ServiceAccount par un RoleBinding dans le namespace dso. Que peut lire ce compte ?
Intention — séparer les trois portes avant les TP 8 et 9. q1 et q5 portent sur RBAC, q2 sur le moment où PSA agit, q3 sur le CNI, q4 sur l'effet de bord de l'egress. Une erreur sur q3 fera perdre une demi-heure au TP 9.
Ce qui coince
- q2 ratée : il croira que PSA nettoie l'existant et s'étonnera de voir
attaquantsurvivre à l'étiquetage. C'est au contraire un point d'audit majeur — le reprendre. - q4 ratée : il passera le TP 9 à croire que sa politique de sortie est cassée alors qu'elle fonctionne trop bien.
Questions à anticiper
- « La q5 : un Role ne peut vraiment rien faire hors de son namespace ? » — confirmer, et préciser que le ClusterRoleBinding est la seule façon d'élargir.
Ce qu'un échec révèle — 4/5 est le seuil utile ici. q2 ou q3 fausses : reprendre avant le TP 8, cinq minutes suffisent.
PodSecurityAdmission : le pod que le cluster refuse d'écrire
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 20 min
RBAC répond à « avez-vous le droit d'écrire cet objet ». PodSecurityAdmission répond à une question différente : « cet objet, tel que vous l'écrivez, est-il licite ici ? ». Un compte peut être parfaitement autorisé à créer des pods et se voir refuser ce pod-là.
Deux axes qu'il ne faut pas mélanger
Trois niveaux, du plus permissif au plus strict :
| Niveau | Ce qu'il autorise |
|---|---|
privileged | Tout. C'est l'absence de contrainte — le défaut d'un namespace non étiqueté. |
baseline | Interdit les privilèges les plus évidents : privileged, hostNetwork, hostPID, montages hôte. |
restricted | Impose en plus l'exécution non root, l'abandon de toutes les capabilities et un profil seccomp. |
Et trois modes, qui décident de ce qui arrive en cas de non-conformité :
enforce refuse l'objet, audit l'inscrit au journal d'audit,
warn affiche un avertissement à celui qui applique le manifeste. Ils se combinent
librement — warn avant enforce est la façon prudente de migrer un
namespace existant.
apiVersion: v1
kind: Namespace
metadata:
name: dso
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restrictedLes cinq exigences de restricted
Ce sont elles que le message de refus énumère. Apprenez-les : le message devient alors une liste de correctifs.
| Exigence | Écriture dans le manifeste |
|---|---|
| Ne pas tourner en root | runAsNonRoot: true |
| Pas d'élévation de privilège | allowPrivilegeEscalation: false |
| Aucune capability | capabilities: drop: ["ALL"] |
| Profil seccomp explicite | seccompProfile: type: RuntimeDefault |
| Pas de conteneur privilégié | privileged absent ou false |
À l'admission — l'objet est refusé, il n'existe jamais. Message violates PodSecurity "restricted:latest", avec la liste des points en faute.
Au démarrage — l'objet est accepté, le pod est planifié, et le kubelet refuse de lancer le conteneur. Cas le plus fréquent : runAsNonRoot: true sur une image dont l'USER est un nom et non un identifiant numérique. Message CreateContainerConfigError, et un pod qui reste bloqué.
C'est pour cela que l'USER 10001 d'hier était numérique.
Ce qui remplace, et ce qui vient après
- PodSecurityPolicy est mort. Supprimé en 1.25. Tout tutoriel qui en parle est périmé — ne perdez pas une heure à le faire fonctionner.
- PSA ne juge que le pod. Il ne sait rien dire de l'image utilisée, du registre d'origine ou des étiquettes obligatoires. Pour cela il faut un contrôleur de politiques — Kyverno ou Gatekeeper. Hors programme, à savoir citer.
- PSA n'agit qu'à l'écriture. Étiqueter un namespace ne touche pas aux pods qui y tournent déjà. Un audit sérieux vérifie donc l'étiquette et les pods en place.
Intention — donner les cinq exigences de restricted avant le TP, pour qu'il lise le message de refus comme une liste de correctifs et non comme un mur. Et poser la distinction admission / kubelet, qui explique les deux moments possibles de l'échec.
Ce qui coince
- Les trois niveaux confondus avec les trois modes. Trois niveaux (privileged, baseline, restricted) × trois modes (enforce, audit, warn) : ce sont deux axes. Le faire dire.
restrictedappliqué d'emblée partout, y compris là où il ne peut pas marcher (kube-bench de jeudi a besoin dehostPID). C'est un bon problème, il le rencontrera.- PSP (PodSecurityPolicy) trouvé dans les tutoriels : supprimé en 1.25. Le dire une fois pour qu'il ne perde pas une heure.
Questions à anticiper
- « Et si j'ai vraiment besoin d'un pod privilégié ? » — namespace séparé avec un niveau plus bas, et la justification écrite. Jamais l'assouplissement du namespace applicatif.
- « Kyverno / Gatekeeper ? » — la suite logique quand PSA ne suffit plus (règles sur les images, les labels, les registres autorisés). Culture, pas de déploiement.
Ce qu'il faut exiger — qu'il cite quatre des cinq exigences de restricted sans la page, et qu'il sache dire laquelle échoue à l'admission et laquelle échoue au démarrage.
TP 8 — Ce que PSA empêche : un pod root refusé à l'écriture
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 1 h
Obtenir un refus d'admission sur un pod privilégié, puis rendre le déploiement applicatif conforme à restricted.
- Avant toute étiquette, écrivez le pod délibérément non conforme et appliquez-le. Il va être accepté — c'est le point de départ.
# k8s/pod-root.yaml — deliberement non conforme apiVersion: v1 kind: Pod metadata: name: pod-root namespace: dso spec: containers: - name: root image: busybox:1.36 command: ["sh", "-c", "id; sleep 3600"] securityContext: runAsUser: 0 privileged: true - Appliquez-le et constatez.
kubectl apply -f k8s/pod-root.yaml kubectl -n dso get pod pod-root kubectl -n dso logs pod-root | head -3 - Le pod tourne, et ses journaux affichent
uid=0(root). Un conteneur privilégié, en root, dans votre namespace applicatif : c'est exactement ce que la leçon d'hier vous a appris à craindre. Supprimez-le avant de continuer :kubectl -n dso delete pod pod-root --now. - Écrivez
k8s/psa-namespace.yamlet appliquez-le.enforcerefuse,warnprévient — les deux ensemble, pour que les avertissements restent visibles sur les objets acceptés de justesse.# k8s/psa-namespace.yaml apiVersion: v1 kind: Namespace metadata: name: dso labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/warn: restricted - Appliquez l'étiquette, puis vérifiez qu'elle est bien posée.
kubectl apply -f k8s/psa-namespace.yaml kubectl get ns dso --show-labels - Rejouez le pod root. Cette fois, il ne sera pas écrit du tout.
kubectl apply -f k8s/pod-root.yaml - Lisez le message en entier. Il énumère cinq violations :
privileged,allowPrivilegeEscalation != false,unrestricted capabilities,runAsNonRoot != true,seccompProfile. Chacune correspond à une ligne de manifeste. Copiez ce message intégralement : il ira dans votre journal et dans votre rapport. - Prenez l'habitude de tester avant d'appliquer.
--dry-run=serverpasse par l'admission sans rien écrire ;--dry-run=clientne teste rien du tout.kubectl apply -f k8s/pod-root.yaml --dry-run=server kubectl apply -f k8s/deployment.yaml --dry-run=server - Votre déploiement applicatif est refusé lui aussi. Rendez-le conforme : ajoutez le
securityContext, et prévoyez un volume inscriptible puisquereadOnlyRootFilesysteminterdit d'écrire ailleurs.# k8s/deployment.yaml — bloc a ajouter dans le conteneur "app" - name: app image: devsecops-lab:hardened imagePullPolicy: IfNotPresent workingDir: /tmp command: ["python", "/app/app.py"] ports: - containerPort: 5000 securityContext: runAsNonRoot: true runAsUser: 10001 allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault volumeMounts: - name: scratch mountPath: /tmp readinessProbe: httpGet: path: / port: 5000 initialDelaySeconds: 3 volumes: - name: scratch emptyDir: {} - Appliquez et suivez le déploiement. S'il boucle, lisez les journaux avant tout autre geste.
kubectl apply -f k8s/deployment.yaml kubectl -n dso rollout status deploy/devsecops-lab --timeout=180s kubectl -n dso get pod -o wide - Vérifiez les deux choses qui comptent : l'application répond, et elle tourne bien en utilisateur non privilégié avec un système de fichiers en lecture seule.
kubectl -n dso port-forward svc/devsecops-lab 5001:5000 & sleep 2 && curl -s http://localhost:5001/ ; echo kill %1 2>/dev/null POD=$(kubectl -n dso get pod -l app=devsecops-lab \ -o jsonpath='{.items[0].metadata.name}') kubectl -n dso exec "$POD" -- id kubectl -n dso exec "$POD" -- sh -c 'touch /app/test 2>&1 || echo "lecture seule : OK"' - Rendez enfin le pod
attaquantconforme — vous en aurez besoin au TP suivant, et il est actuellement refusé lui aussi. Réécrivezk8s/attaquant.yamlen entier :# k8s/attaquant.yaml — version conforme au niveau restricted apiVersion: v1 kind: Pod metadata: name: attaquant namespace: dso labels: role: audit spec: serviceAccountName: lab-sa containers: - name: curl image: curlimages/curl:8.8.0 command: ["sleep", "36000"] securityContext: runAsNonRoot: true runAsUser: 100 allowPrivilegeEscalation: false capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault - Recréez-le et vérifiez qu'il passe l'admission.
kubectl -n dso delete pod attaquant --now --ignore-not-found kubectl apply -f k8s/attaquant.yaml kubectl -n dso wait --for=condition=Ready pod/attaquant --timeout=90s - Consignez et committez.
cat >> journal-incidents.md <<'EOF' ## 2026-08-12 — PodSecurityAdmission (M3 J2) - Avant etiquetage : pod privilegie en root ACCEPTE dans dso - Apres enforce=restricted : refus a l admission, 5 violations listees <coller le message complet> - Pods deja en marche NON affectes : PSA ne juge qu a l ecriture - deployment.yaml rendu conforme : runAsNonRoot, drop ALL, seccomp, readOnlyRootFilesystem + emptyDir sur /tmp pour la base SQLite EOF git add k8s/ journal-incidents.md git commit -S -m "feat(k8s): namespace restricted, deploiement conforme a PSA"
- PSA n'a pas touché aux pods déjà en marche. Quelle conséquence pour un auditeur qui ne vérifierait que les étiquettes de namespace ?
readOnlyRootFilesystem: truea cassé l'application. Pourquoi est-ce malgré tout la bonne configuration, et qu'est-ce que le correctif révèle du programme ?- Un pod d'infrastructure a réellement besoin de
hostPID. Comment le déployez-vous sans assouplir le namespace applicatif ?
Lisez le message de refus en entier. Il n'y a pas une faute mais cinq, listées une par une, et chacune correspond à une ligne à écrire. C'est le message d'erreur le plus utile de tout Kubernetes : ne le survolez pas.
Le pod attaquant créé au TP 7 va survivre à l'étiquetage. Ce n'est pas un bug : PSA ne juge qu'à l'écriture. Vous le supprimerez et le recréerez, et il sera alors refusé à son tour — ce qui vous obligera à le rendre conforme avant le TP 9. Anticipez-le, sinon vous le découvrirez au pire moment.
Intention — provoquer le refus, le lire, en déduire les correctifs. Puis amener deployment.yaml à l'état conforme, avec readOnlyRootFilesystem — qui casse l'écriture de SQLite et se résout par un emptyDir. C'est le passage le plus formateur de la journée.
Ce qui coince
- Il applique le namespace étiqueté avant d'avoir tenté le pod root, et rate toute la démonstration. Tenir l'ordre.
readOnlyRootFilesystem: truesansemptyDir: l'application redémarre en boucle avec l'erreur SQLite d'hier. La cause n'est plus la même — c'est le système de fichiers en lecture seule — et c'est ce qu'il doit diagnostiquer seul.- Le
workingDir: /tmpet lecommandexplicite oubliés : sans eux, la base est créée dans/app, qui n'est plus inscriptible. --dry-run=serverconfondu avec--dry-run=client. Seul le premier passe par l'admission ; le second ne teste rien du tout.
Questions à anticiper
- « Pourquoi une
readinessProbealors que j'ai unHEALTHCHECK? » — Kubernetes ignore leHEALTHCHECKde l'image. Point à faire dire, il revient en soutenance. - «
emptyDir, c'est persistant ? » — non, la vie du pod. Ici c'est exactement ce qu'on veut, et c'est aussi ce qui rend la base de démonstration jetable.
Ce qu'il faut exiger — le message de refus complet collé dans le journal, et le déploiement conforme qui répond en curl. Un déploiement conforme mais en CrashLoopBackOff n'est pas un succès.
TP 9 — Ce que NetworkPolicy empêche : le déplacement latéral
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 1 h 15
Bloquer, puis autoriser précisément, les flux entre pods — et fermer la sortie du namespace.
Si kubectl -n kube-system get ds calico-node ne montre pas 2 2 2, arrêtez-vous là : sans Calico, vos politiques seront acceptées par l'API et n'empêcheront rien. Vous chercheriez une erreur dans votre YAML pendant une heure.
- Mesure de référence : depuis le pod
attaquant, joignez l'application. Aucune politique n'existe encore — le réseau du cluster est plat.kubectl -n dso exec attaquant -- \ curl -s -m 5 -o /dev/null -w '%{http_code}\n' \ http://devsecops-lab.dso.svc.cluster.local:5000/ - Vous lisez
200. Un pod quelconque du namespace joint l'application sur son port applicatif, sans authentification ni restriction. C'est le comportement par défaut de Kubernetes, et c'est ce qui rend le déplacement latéral si simple après une première compromission. - Écrivez
k8s/netpol.yaml: le refus par défaut de toute entrée dans le namespace.podSelector: {}désigne tous les pods.# k8s/netpol.yaml — default-deny en entree sur le namespace dso apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-ingress namespace: dso spec: podSelector: {} policyTypes: [ Ingress ] - Appliquez, puis rejouez exactement la même mesure.
kubectl apply -f k8s/netpol.yaml kubectl -n dso get netpol kubectl -n dso exec attaquant -- \ curl -s -m 5 -o /dev/null -w '%{http_code}\n' \ http://devsecops-lab.dso.svc.cluster.local:5000/ - Le
curlexpire au bout de cinq secondes : plus aucun flux entrant n'atteint les pods dedso. Vérifiez au passage que votre port-forward depuis le poste fonctionne toujours — il passe par l'API, pas par le réseau des pods, et n'est donc pas concerné. - Ouvrez maintenant le seul flux légitime. Attention aux deux
podSelector: celui du haut désigne qui est protégé, celui du blocfromdésigne qui entre.# k8s/netpol-allow.yaml — la seule entree autorisee apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-front-vers-lab namespace: dso spec: podSelector: matchLabels: app: devsecops-lab policyTypes: [ Ingress ] ingress: - from: - podSelector: matchLabels: role: front ports: - protocol: TCP port: 5000 - Appliquez, et rejouez la mesure. Elle doit toujours échouer : le pod
attaquantne porte pas l'étiquetterole: front.kubectl apply -f k8s/netpol-allow.yaml kubectl -n dso exec attaquant -- \ curl -s -m 5 -o /dev/null -w '%{http_code}\n' \ http://devsecops-lab.dso.svc.cluster.local:5000/ - Posez l'étiquette sur le pod, et mesurez une troisième fois.
kubectl -n dso label pod attaquant role=front --overwrite kubectl -n dso exec attaquant -- \ curl -s -m 5 -o /dev/null -w '%{http_code}\n' \ http://devsecops-lab.dso.svc.cluster.local:5000/ 200. Vous avez fait passer le même pod de « bloqué » à « autorisé » en changeant une étiquette, sans toucher au réseau ni à l'application. C'est le modèle de Kubernetes : l'identité d'un flux, c'est une étiquette.- Passons à la sortie. Fermez tout l'egress du namespace, sans exception, et regardez ce qui casse.
cat > /tmp/egress-brut.yaml <<'EOF' apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-egress namespace: dso spec: podSelector: {} policyTypes: [ Egress ] EOF kubectl apply -f /tmp/egress-brut.yaml kubectl -n dso exec attaquant -- \ curl -s -m 5 http://devsecops-lab.dso.svc.cluster.local:5000/ ; echo "code=$?" - Le message n'est plus un délai dépassé mais
could not resolve host. Vous avez bloqué le DNS : interroger CoreDNS est un flux sortant comme un autre. C'est l'erreur la plus fréquente sur les politiques d'egress, et vous venez de la produire volontairement. - Écrivez la version correcte, celle qui autorise le DNS et rien d'autre, et appliquez-la à la place.
# k8s/netpol-egress.yaml — plus rien ne sort, sauf le DNS apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-egress namespace: dso spec: podSelector: {} policyTypes: [ Egress ] 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 - Appliquez, puis vérifiez les deux comportements attendus : l'intérieur du cluster reste joignable, l'extérieur ne l'est plus.
kubectl apply -f k8s/netpol-egress.yaml echo '--- interne : doit repondre 200 ---' kubectl -n dso exec attaquant -- \ curl -s -m 5 -o /dev/null -w '%{http_code}\n' \ http://devsecops-lab.dso.svc.cluster.local:5000/ echo '--- externe : doit echouer ---' kubectl -n dso exec attaquant -- \ curl -s -m 5 -o /dev/null -w '%{http_code}\n' https://example.com/ \ || echo 'sortie bloquee — OK' - Retenez ce dernier résultat :
devsecops-labporte une SSRF (VULN-08) que vous exploiterez au M5. Cette politique de sortie ne corrige pas la faille — elle empêche l'attaquant d'en tirer quoi que ce soit vers l'extérieur. Deux couches indépendantes, et il faut les deux. - Consignez les trois mesures et committez.
cat >> journal-incidents.md <<'EOF' ## 2026-08-12 — NetworkPolicy (M3 J2) - Sans politique : curl attaquant -> devsecops-lab = 200 - default-deny-ingress : timeout apres 5 s - allow-front-vers-lab : toujours bloque (pas d etiquette role=front) - apres label role=front : 200 - default-deny-egress sans exception DNS : could not resolve host - egress + exception port 53 UDP/TCP : interne 200, externe bloque - Lien VULN-08 (SSRF) : la politique de sortie limite l exploitation sans corriger la faille. Deux couches, les deux necessaires. EOF git add k8s/netpol.yaml k8s/netpol-allow.yaml k8s/netpol-egress.yaml journal-incidents.md git commit -S -m "feat(k8s): default-deny ingress et egress, flux autorises par etiquette"
- Aucun message d'erreur n'accompagne un flux bloqué. Quelle conséquence sur le diagnostic en production, et comment vous y prendriez-vous ?
- Vous avez fait passer un pod de bloqué à autorisé en posant une étiquette. Qui, dans votre cluster, a le droit de poser des étiquettes sur les pods — et pourquoi cette question devient-elle une question de sécurité ?
- La politique de sortie limite l'exploitation d'une SSRF sans corriger la faille. Formulez en deux phrases pourquoi il faut malgré tout corriger le code.
Un flux bloqué par une NetworkPolicy ne produit aucun message d'erreur : la connexion n'aboutit pas, un point c'est tout. Utilisez toujours curl -m 5 — sans délai maximal, vous attendrez plusieurs minutes en croyant à un blocage de votre terminal.
Deux symptômes, deux causes : un timeout signifie que la connexion TCP est refusée par la politique ; un could not resolve host signifie que c'est le DNS qui est bloqué, donc votre politique de sortie. Savoir les distinguer vous fera gagner un quart d'heure à chaque fois.
Intention — montrer que le réseau d'un cluster est plat par défaut : n'importe quel pod parle à n'importe quel pod, dans le namespace et souvent au-delà. Puis construire la liste blanche. Le volet egress boucle sur VULN-08 (SSRF) : la politique de sortie est ce qui aurait empêché l'exfiltration, indépendamment du correctif applicatif du M5.
Ce qui coince
- Sans
-m, lecurlpend. Il croit son terminal figé et ouvre un second onglet. Insister sur le délai maximal dès la première commande. - La politique allow écrite avec le mauvais
podSelector: il sélectionne l'émetteur au lieu de la cible. LepodSelectorde premier niveau désigne qui est protégé ; celui du blocfromdésigne qui a le droit d'entrer. - L'egress sans exception DNS : tout casse, y compris ce qui devrait marcher. C'est voulu dans le déroulé — le laisser buter deux minutes, puis donner la règle.
- S'il a monté le cluster sans Calico (ou l'a recréé sans la config), rien ne bloque et il cherche l'erreur dans son YAML. Vérifier
kubectl -n kube-system get ds calico-nodeavant de chercher ailleurs.
Questions à anticiper
- « Pourquoi le port 53 en UDP et TCP ? » — les réponses longues basculent en TCP. Détail qui casse un cluster en production une fois par an.
- « Une NetworkPolicy protège-t-elle des autres namespaces ? » — oui si elle est en
default-deny: tout ce qui n'est pas autorisé est refusé, y compris l'extérieur du namespace.
Ce qu'il faut exiger — trois mesures du même curl : 200 avant la politique, échec après, 200 à nouveau une fois l'étiquette role=front posée. Les trois, notées.
TP 10 — Le carnet des trois refus
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 40 min
Écrire la section 2 du dossier : pour chaque garde-fou, ce qu'il empêche, la preuve et le message exact.
- Ouvrez
rapports/durcissement-conteneurs.mdà la section 2. Vous allez écrire trois blocs de structure identique — c'est cette régularité qui rend un rapport lisible. - Le gabarit d'un bloc. Recopiez-le trois fois et remplissez-le.
### 2.x — <nom du garde-fou> Question tranchee : <en une phrase> Manifeste : k8s/<fichier>.yaml Avant | commande : ... | resultat : ... Apres | commande : ... | resultat : ... Message exact obtenu : ``` <coller le message integral, sans le reecrire> ``` Ce que ce garde-fou n empeche PAS : ... - Bloc 1 — RBAC. Les deux sorties de
can-iet la réponse de l'API depuis le pod.### 2.1 — RBAC Question tranchee : ce compte a-t-il le droit de lire les Secrets du namespace ? Manifeste : k8s/rbac.yaml Avant | kubectl auth can-i get secrets --as=system:serviceaccount:dso:lab-sa -n dso | yes — et exfiltration reussie depuis le pod (JSON des Secrets) Apres | meme commande | no — et plus aucun jeton monte dans le pod Ce que RBAC n empeche PAS : qu un pod tourne en root, ni qu il parle a n importe quel autre pod du cluster. - Bloc 2 — PodSecurityAdmission. Le message de refus complet, cinq violations comprises.
### 2.2 — PodSecurityAdmission Question tranchee : ce pod, tel qu il est ecrit, est-il licite dans ce namespace ? Manifeste : k8s/psa-namespace.yaml Avant | kubectl apply -f k8s/pod-root.yaml | pod cree, uid=0(root), privileged Apres | meme commande, namespace etiquete enforce=restricted | refus a l admission, 5 violations Ce que PSA n empeche PAS : les pods deja en marche avant l etiquetage, ni aucun flux reseau. - Bloc 3 — NetworkPolicy. Les trois mesures du même
curl, plus l'incident DNS.### 2.3 — NetworkPolicy (avec Calico v3.28.0) Question tranchee : ce pod a-t-il le droit de parler a celui-la ? Manifestes : k8s/netpol.yaml, netpol-allow.yaml, netpol-egress.yaml Mesure unique, repetee : curl -m 5 depuis le pod attaquant vers le service 1. sans politique -> 200 2. default-deny-ingress -> timeout 3. allow-front + etiquette role=front -> 200 4. default-deny-egress sans DNS -> could not resolve host 5. egress + port 53 UDP/TCP -> interne 200, externe bloque Prerequis critique : kindnet n applique PAS les NetworkPolicy. Sans Calico, les politiques sont acceptees par l API et ne bloquent rien. Ce qu une NetworkPolicy n empeche PAS : la lecture d un Secret par l API, ni l execution d un conteneur privilegie. - Ajoutez la synthèse en tête de section — trois lignes, c'est ce que votre encadrant lira en premier.
## 2. Cluster — ce que chaque garde-fou empeche | Garde-fou | Question tranchee | Refus observe | |-----------------|------------------------------|--------------------------| | RBAC | avez-vous le droit ? | 403 Forbidden, cannot get | | PSA | l objet est-il licite ? | refus a l admission | | NetworkPolicy | ce flux est-il autorise ? | aucun message, timeout | Les trois sont independants : aucun ne compense l absence des deux autres. - Committez.
git add rapports/durcissement-conteneurs.md git commit -S -m "docs(M3): section 2, les trois refus demontres" git push
- Les trois garde-fous répondent à trois questions distinctes. Y a-t-il un ordre dans lequel il vaut mieux les poser sur un cluster existant ?
Intention — figer les trois démonstrations pendant qu'elles sont fraîches, et surtout leurs messages d'erreur exacts. Sans cette demi-heure, le rapport de jeudi contiendra « PSA refuse les pods root » sans une seule preuve.
Ce qui coince
- Les messages paraphrasés au lieu d'être collés. Exiger le copier-coller intégral : c'est ce qui distingue une preuve d'une affirmation.
- La ligne « ce que cela n'empêche pas » sautée. C'est pourtant la plus instructive : RBAC n'empêche pas un pod root, PSA n'empêche pas un flux, une NetworkPolicy n'empêche pas de lire un Secret.
Questions à anticiper
- « Je mets les YAML dans le rapport ? » — non, ils sont dans
k8s/et versionnés ; le rapport les cite par chemin.
Ce qu'il faut exiger — trois blocs, chacun avec la commande, le résultat avant, le résultat après, et une ligne « ce que ce garde-fou n'empêche pas ».
M3 · J2 — Trois refus obtenus : RBAC, PSA, NetworkPolicy
Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 30 min
Trois commandes, trois refus, en trois minutes. Préparez un terminal avec le contexte kind-dso-lab actif et le pod attaquant en marche.
La question qui vient toujours : lequel de ces trois garde-fous auriez-vous posé en premier sur un cluster existant en production, et pourquoi ? Il n'y a pas de bonne réponse unique — il y a des réponses argumentées et des réponses vides.
Ne détruisez pas le cluster ce soir. Toute la journée de jeudi s'appuie dessus.
Intention — vérifier que les trois démonstrations ont réellement eu lieu, avec la mesure avant et la mesure après. Une configuration correcte qu'on n'a jamais vue refuser quoi que ce soit ne prouve rien — c'est le cœur du point.
Ce qu'il faut exiger — trois gestes, faits par lui, dans cet ordre :
kubectl auth can-i get secrets --as=system:serviceaccount:dso:lab-sa -n dso→no.kubectl apply -f k8s/pod-root.yaml→ refus d'admission, et il lit à voix haute deux des cinq violations.curl -m 5depuisattaquantaprès retrait de l'étiquetterole=front→ échec ; étiquette remise →200. Le faire devant vous, c'est trente secondes.
Ce qui coince
- Cluster monté sans Calico : tout le volet réseau est un faux positif. Vérifier
kubectl -n kube-system get ds calico-nodeen premier. - Messages d'erreur paraphrasés dans le rapport. Demander à voir le bloc collé.
- Déploiement conforme mais en
CrashLoopBackOff: la conformité PSA sans application qui répond n'est pas un résultat.
Questions à anticiper
- « Je peux détruire le cluster, je le remonterai demain ? » — non. Le remontage coûte 20 minutes et la journée de jeudi en dépend entièrement.
Ne pas valider si — l'un des trois refus n'a jamais été observé, ou si le pod attaquant n'a pas été recréé après la version 2 du RBAC : dans ce cas il porte encore son ancien jeton et la démonstration est trompeuse.
Un Secret Kubernetes n'est pas chiffré
Jour 3 · Secrets, gate d'image et audit · 25 min
C'est la croyance la plus répandue de tout l'écosystème, et elle est fausse : un Secret Kubernetes n'est pas chiffré. Ses valeurs sont encodées en base64, et base64 se défait sans clé, en une commande, par n'importe qui.
kubectl -n dso get secret lab-secrets -o jsonpath='{.data.API_KEY}' | base64 -dBase64 n'est pas là pour protéger : il est là pour transporter proprement des octets quelconques — clés binaires, certificats, valeurs avec retours à la ligne — dans un document YAML. C'est un format d'encodage, au même titre que l'hexadécimal.
Un encodage se défait sans clé : c'est une transformation publique. Un chiffrement exige une clé que l'attaquant n'a pas. Aucune clé n'intervient dans base64 — donc aucune protection.
Alors qu'est-ce qu'un Secret protège réellement ?
Il apporte trois choses, et il faut savoir les nommer :
- Un type d'objet distinct, sur lequel on peut poser des droits RBAC séparés — ce que ne permet pas une ConfigMap.
- Une absence de journalisation : la valeur n'apparaît ni dans
kubectl describe, ni dans les événements du cluster. - Un stockage en tmpfs quand il est monté en volume : le contenu reste en mémoire du nœud, jamais écrit sur le disque du pod.
C'est déjà beaucoup. Ce n'est pas un coffre-fort.
Les quatre chemins de lecture
| Qui | Comment | Ce qui limite |
|---|---|---|
Un compte avec get secrets | kubectl get secret -o yaml puis base64 -d | RBAC — c'est le levier principal, celui de votre TP d'hier |
| Un pod qui monte le Secret | Lecture du fichier, ou de /proc/1/environ si c'est une variable | N'en monter que ce dont le pod a besoin, et jamais en variable |
Quiconque lit etcd | etcdctl get /registry/secrets/<ns>/<nom> | Le chiffrement at-rest — absent par défaut |
Quiconque a une sauvegarde d'etcd | Le même contenu, hors ligne, sans aucune trace | Chiffrer les sauvegardes, et les traiter comme des secrets |
Par défaut, un cluster Kubernetes écrit ses Secrets en clair dans la base etcd. Il faut une EncryptionConfiguration explicite côté apiserver — le plus souvent adossée à un KMS — pour que ce ne soit plus le cas. Ni kind, ni kubeadm par défaut ne l'activent.
Conséquence pratique : une sauvegarde d'etcd qui traîne sur un partage réseau, c'est l'intégralité des secrets du cluster, hors ligne et sans trace d'accès.
Variable d'environnement ou fichier monté ?
| Variable d'environnement | Volume monté | |
|---|---|---|
Visible dans /proc/<pid>/environ | Oui | Non |
| Récupérée par une trace de plantage ou un outil d'observabilité | Souvent | Non |
| Héritée par les processus enfants | Oui | Non |
| Mise à jour sans redémarrer le pod | Non | Oui, le kubelet rafraîchit le fichier |
| Permissions réglables | Non | Oui — defaultMode: 0400 |
La conclusion est nette et vaut comme règle : monter un fichier, jamais une variable d'environnement. L'application lit le fichier au démarrage.
Ce qui existe au-delà, et qu'on ne déploiera pas ici
- Chiffrement at-rest —
EncryptionConfigurationavecaescbcou un KMS externe. C'est la vraie réponse au cheminetcd. - External Secrets Operator — le Secret est synchronisé depuis un coffre (Vault, AWS Secrets Manager) et n'existe dans le cluster que le temps utile.
- Sealed Secrets — la version chiffrée est versionnable dans Git ; seul le contrôleur du cluster sait la déchiffrer. Réponse directe au « comment versionner un secret ».
- SOPS — chiffrement de fichiers YAML champ par champ, indépendant de Kubernetes.
Chacun demande une infrastructure de clés à maintenir plusieurs jours, pour une démonstration que votre poste ne rendrait pas plus convaincante. Ce que vous devez savoir en sortant : nommer ces outils, dire à quel chemin de lecture chacun répond, et avoir vu de vos yeux ce qui se passe sans eux. C'est l'objet de l'heure qui vient.
La phrase à retenir mot pour mot : base64 est un encodage, pas un chiffrement. Un encodage se défait sans clé, en une commande, par n'importe qui. S'il fallait une clé, ce serait un chiffrement — et il n'y en a pas.
Le vrai sujet n'est donc pas « le Secret est-il protégé » mais « qui peut le lire ». La réponse tient en quatre entrées : tout compte ayant get secrets dans le namespace, tout pod qui le monte, quiconque lit la base etcd ou une de ses sauvegardes, et l'administrateur du nœud. Vous allez vérifier les quatre.
Intention — casser la croyance la plus tenace de Kubernetes, puis la remplacer par une posture réaliste : le Secret est un objet à droits, pas un coffre. Tout le TP suivant en découle, y compris la lecture directe dans etcd, qui marque durablement.
Ce qui coince
- « Donc il ne faut pas utiliser les Secrets. » Non : il faut les utiliser et poser RBAC, le chiffrement at-rest et un gestionnaire externe. Le nihilisme conduit à remettre les mots de passe dans les ConfigMap, ce qui est pire.
- Variable d'environnement crue équivalente au montage fichier. Elle fuit par
/proc/<pid>/environ, par les traces de plantage, par les journaux applicatifs et parkubectl describe. Le montage fichier n'a aucun de ces chemins. - Le chiffrement at-rest confondu avec le chiffrement du transport. TLS protège entre le client et l'apiserver ; il ne change rien à ce qui est écrit sur disque.
Questions à anticiper
- « Pourquoi base64 alors, si ça ne protège rien ? » — pour transporter du binaire et des retours à la ligne dans du YAML. C'est un format, pas une mesure de sécurité.
- « Vault, on l'installe ? » — non, hors budget et hors périmètre. Le citer, dire ce qu'il apporte (rotation, baux courts, audit), passer.
Ce qu'il faut exiger — qu'il énumère les quatre chemins de lecture sans la page, avant de lancer le TP.
TP 11 — Décoder un Secret, puis le lire directement dans etcd
Jour 3 · Secrets, gate d'image et audit · 1 h
Vérifier les quatre chemins de lecture d'un Secret, et réduire ceux qui peuvent l'être.
Le cluster dso-lab d'hier doit être en marche. kubectl get nodes pour vérifier ; s'il est arrêté, docker start dso-lab-control-plane dso-lab-worker puis attendez une minute que les composants remontent.
- Premier chemin : l'API, avec les droits qui vont bien. Vous êtes administrateur de votre cluster, donc vous les avez.
kubectl -n dso get secret lab-secrets -o yaml echo "--- decodage ---" kubectl -n dso get secret lab-secrets \ -o jsonpath='{.data.API_KEY}' | base64 -d ; echo - Vous lisez
AKIAIOSFODNN7EXAMPLEen clair. Une commande, aucune clé, aucun privilège particulier au-delà deget secrets. C'est ce que RBAC protège — et rien d'autre ne le protège. - Deuxième chemin : le pod. Injectez le Secret en variables d'environnement, comme le font neuf déploiements sur dix, et regardez par où il fuit.
kubectl -n dso set env deploy/devsecops-lab \ --from=secret/lab-secrets kubectl -n dso rollout status deploy/devsecops-lab --timeout=120s POD=$(kubectl -n dso get pod -l app=devsecops-lab \ -o jsonpath='{.items[0].metadata.name}') kubectl -n dso exec "$POD" -- env | grep -E 'API_KEY|SECRET_KEY' kubectl -n dso exec "$POD" -- cat /proc/1/environ | tr '\0' '\n' | grep AKIA kubectl -n dso describe pod "$POD" | grep -A3 Environment - Trois observations : la valeur est dans
env, elle est dans/proc/1/environ— donc lisible par tout processus du conteneur, y compris un processus injecté par un attaquant — etdescriberévèle au moins le nom des variables, ce qui suffit à orienter une attaque. - Troisième chemin, le plus parlant : la base de données du cluster. Vous allez lire le Secret directement dans
etcd, sans passer par l'API.ETCD=$(kubectl -n kube-system get pod -l component=etcd \ -o jsonpath='{.items[0].metadata.name}') echo "pod etcd : $ETCD" kubectl -n kube-system exec "$ETCD" -- sh -c \ 'ETCDCTL_API=3 etcdctl \ --cacert /etc/kubernetes/pki/etcd/ca.crt \ --cert /etc/kubernetes/pki/etcd/server.crt \ --key /etc/kubernetes/pki/etcd/server.key \ get /registry/secrets/dso/lab-secrets' | grep -a AKIA - La sortie est du binaire, et votre chaîne
AKIA…est dedans, en clair. Vous venez de contourner entièrement RBAC : quiconque atteint le nœud de contrôle, ou récupère une sauvegarde d'etcd, obtient tous les secrets du cluster sans laisser la moindre trace dans le journal d'audit de l'API. - Vérifiez que le chiffrement at-rest n'est effectivement pas activé. C'est une démonstration par la négative, et elle est parfaitement recevable dans un rapport.
kubectl -n kube-system get pod \ kube-apiserver-dso-lab-control-plane \ -o jsonpath='{.spec.containers[0].command}' | tr ',' '\n' \ | grep -i encryption || echo "AUCUN --encryption-provider-config : etcd est en clair" - Réduisons maintenant ce qui peut l'être. Premier geste : retirer les variables d'environnement et monter un fichier, en lecture seule pour le seul propriétaire.
kubectl -n dso set env deploy/devsecops-lab \ API_KEY- SECRET_KEY- # Puis, dans k8s/deployment.yaml, sous le conteneur app : # volumeMounts: # - name: secrets # mountPath: /etc/lab-secrets # readOnly: true # volumes: # - name: secrets # secret: # secretName: lab-secrets # defaultMode: 0400 - Appliquez et vérifiez les permissions du fichier monté.
kubectl apply -f k8s/deployment.yaml kubectl -n dso rollout status deploy/devsecops-lab --timeout=180s POD=$(kubectl -n dso get pod -l app=devsecops-lab \ -o jsonpath='{.items[0].metadata.name}') kubectl -n dso exec "$POD" -- ls -l /etc/lab-secrets/ kubectl -n dso exec "$POD" -- env | grep -c AKIA \ || echo 'plus aucune valeur dans l environnement — OK' - Deuxième geste : vérifier que le compte de service ne peut toujours pas lire les Secrets par l'API. C'est le travail d'hier, on s'assure qu'il tient.
kubectl auth can-i get secrets \ --as=system:serviceaccount:dso:lab-sa -n dso kubectl auth can-i --list \ --as=system:serviceaccount:dso:lab-sa -n dso - Consignez les quatre chemins et l'état de chacun après vos corrections.
cat >> journal-incidents.md <<'EOF' ## 2026-08-13 — Secrets Kubernetes (M3 J3) | Chemin de lecture | Etat avant | Etat apres | Ce qui le ferme | |--------------------------|------------|---------------------|-----------------| | API (get secrets) | ouvert | ferme pour lab-sa | RBAC | | Variables d environnement| ouvert | supprime | montage fichier | | Fichier monte | - | 0400, readOnly | defaultMode | | etcd en clair | OUVERT | TOUJOURS OUVERT | chiffrement at-rest (non fait) | Verifie : aucun --encryption-provider-config sur l apiserver de dso-lab. Les valeurs sont lisibles avec etcdctl depuis le noeud de controle. EOF git add k8s/deployment.yaml journal-incidents.md git commit -S -m "fix(k8s): secrets montes en fichier 0400 au lieu de variables"
- Vous avez lu un Secret sans passer par l'API. Quelle conséquence sur la valeur du journal d'audit de l'apiserver comme preuve d'accès ?
- Un collègue propose de versionner les Secrets dans Git « puisqu'ils sont en base64 ». Répondez-lui en trois phrases, et proposez la solution qui répond à son vrai besoin.
- Parmi les quatre chemins de lecture, lequel n'avez-vous pas pu fermer, et qu'est-ce que cela vous oblige à écrire dans votre rapport ?
La lecture dans etcd renvoie du protobuf : n'attendez pas du texte lisible, attendez votre chaîne au milieu d'octets binaires. C'est justement cela qu'il faut voir — la valeur est là, en clair, dans un fichier de base de données. Si votre terminal se brouille, ajoutez | cat -v.
Et n'oubliez pas de défaire l'étape de la variable d'environnement à la fin. La laisser en place contredirait la conclusion que vous allez écrire.
Intention — remplacer une affirmation de cours par quatre observations. La lecture directe dans etcd est le moment fort : après l'avoir vue, personne ne redit qu'un Secret est chiffré.
Ce qui coince
- Le nom du pod etcd. Il varie avec le nom du cluster (
etcd-dso-lab-control-plane) : la commande fournie le découvre par label, ne pas laisser taper à la main. - Sortie binaire prise pour une erreur. Prévenir avant :
grep -aest là pour ça. kubectl set env --from=secretinjecte toutes les clés. Il s'en étonne. C'est un argument de plus contre les variables.- Il oublie de retirer les variables injectées, et son déploiement reste vulnérable à la fin du module. Le vérifier au point de contrôle.
Questions à anticiper
- « Pourquoi ai-je le droit d'entrer dans le pod etcd ? » — parce qu'il est administrateur de son cluster. Excellente transition vers « qui a
execdanskube-system? ». - « On peut activer le chiffrement at-rest sur kind ? » — techniquement oui, en modifiant le manifeste statique de l'apiserver ; hors budget, et un apiserver cassé ferait perdre la journée. On le démontre par la négative.
Ce qu'il faut exiger — la chaîne AKIA… lue dans la sortie d'etcdctl, et la sortie de la commande qui montre l'absence d'--encryption-provider-config.
Quiz — Secrets, scan d'image et audit
Jour 3 · Secrets, gate d'image et audit · 10 min
1. Pourquoi les valeurs d'un Secret sont-elles encodées en base64 dans le manifeste ?
2. Un attaquant obtient une sauvegarde du répertoire de données d'etcd d'un cluster par défaut. Que peut-il en tirer ?
3. Dans un job de CI, quelle est la différence entre trivy image --severity HIGH avec et sans --exit-code 1 ?
4. Que fait exactement --ignore-unfixed ?
5. kube-bench remonte une trentaine de FAIL sur votre cluster kind. Qu'en concluez-vous pour votre rapport ?
Intention — verrouiller les quatre idées du jour avant la rédaction finale : la nature de base64 (q1), le chemin etcd (q2), le sens de --exit-code (q3), l'effet de --ignore-unfixed (q4) et la lecture d'un rapport CIS (q5).
Ce qui coince
- q4 ratée dans le sens « ça masque les vraies vulnérabilités » : c'est à moitié vrai et il faut le développer, car c'est exactement le débat qu'il aura en entreprise.
- q5 ratée : il va recopier les FAIL de kube-bench dans son rapport comme des constats. Le reprendre avant le TP 13.
Questions à anticiper
- « La q3 : pourquoi ne pas mettre
--exit-code 1sur MEDIUM aussi ? » — parce qu'un gate qui échoue tous les jours est désactivé en trois semaines. La question du seuil est politique autant que technique.
Ce qu'un échec révèle — q1 fausse le dernier jour du module est un signal sérieux : reprendre la démonstration base64 -d en direct.
Le gate d'image : refuser plutôt qu'avertir
Jour 3 · Secrets, gate d'image et audit · 20 min
Vous scannez vos images depuis mardi. Tant que le résultat est un rapport qu'on lit, il ne change rien : les images partent quand même. Un gate est la même commande, avec une conséquence — le pipeline échoue.
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
--format json --output artefacts/trivy-image.json devsecops-lab:hardenedUn seul mot change tout : --exit-code 1. Sans lui, Trivy sort en 0 quoi qu'il
trouve, le job est vert, et l'équipe conclut que l'image est saine.
Le vrai sujet, c'est le seuil
Et ce n'est pas une question technique. Un gate trop strict échoue tous les jours ; au bout de
trois semaines quelqu'un ajoute continue-on-error: true, et vous avez alors le pire
des deux mondes : un contrôle affiché, désactivé, et une équipe qui croit être protégée.
| Réglage | Effet | Tenable ? |
|---|---|---|
--severity LOW,MEDIUM,HIGH,CRITICAL | Rouge en permanence, y compris sur des paquets sans correctif | Non — sera contourné |
--severity HIGH,CRITICAL seul | Rouge sur des advisories que personne ne peut corriger aujourd'hui | Difficilement |
HIGH,CRITICAL + --ignore-unfixed | Rouge uniquement quand un correctif existe et n'a pas été appliqué | Oui — c'est le réglage du parcours |
La formulation qui rend ce réglage défendable : on bloque ce qu'on aurait pu corriger. Ce qui reste — les vulnérabilités sans correctif — n'est pas ignoré : il est documenté dans le rapport, et il devient une raison de changer d'image de base.
Une ligne de .trivyignore sans justification ni date de péremption est une dette qui ne sera jamais revue. Le format accepte une échéance : CVE-2024-XXXXX exp:2026-11-13. Passée cette date, l'exception cesse et le gate redevient rouge — ce qui force la revue.
Trois niveaux de blocage, à ne pas confondre
| Niveau | Ce qu'il empêche | Outil |
|---|---|---|
| Build | Que l'image soit produite ou publiée | trivy image --exit-code 1 dans le pipeline — c'est ce que vous faites aujourd'hui |
| Admission | Qu'une image non conforme soit déployée dans le cluster | Kyverno, Gatekeeper, vérification de signature (cosign) — hors programme |
| Exécution | Qu'un comportement anormal se poursuive | Falco, eBPF — hors programme |
Le premier est le moins cher et le plus utile à ce stade. Sachez dire que les deux autres existent, et à quoi ils répondent : un gate de build n'empêche pas quelqu'un de déployer à la main une image tirée d'ailleurs.
Une image scannée verte lundi peut porter trois CVE critiques le mois suivant : ce n'est pas l'image qui a changé, c'est la base de vulnérabilités. Une chaîne sérieuse rescanne périodiquement les images déjà publiées, indépendamment de tout build.
Intention — faire passer le scan du statut d'information à celui de décision, et poser franchement la question du seuil, qui est la vraie difficulté en entreprise. Prépare le TP 12 et le PFR.
Ce qui coince
- Le seuil placé au maximum par vertu :
--severity LOW, gate rouge tous les jours, désactivé sous trois semaines. Un gate qu'on contourne est pire qu'un gate absent, parce qu'il rassure. .trivyignorecompris comme une poubelle. Une ligne sans date de péremption et sans justification est une dette invisible.- Le gate cru capable d'empêcher un déploiement. Il empêche un build de réussir ; empêcher un déploiement demande un contrôleur d'admission — c'est le niveau au-dessus.
Questions à anticiper
- « Qui décide du seuil ? » — pas l'outil. C'est une décision de risque à écrire quelque part et à revoir. Bonne transition vers le M4.
- « Et si une CVE critique sort après le build ? » — le scan du build ne dit rien du lendemain. D'où le rescan périodique des images publiées, à citer.
Ce qu'il faut exiger — qu'il justifie le seuil HIGH/CRITICAL + --ignore-unfixed par une phrase d'arbitrage, pas par « c'est ce que dit le cours ».
TP 12 — Le job image dans le pipeline, rouge puis vert
Jour 3 · Secrets, gate d'image et audit · 1 h 15
Ajouter au pipeline un scan d'image bloquant, le faire échouer sur l'image du M1, puis passer au vert sur l'image durcie.
Votre .github/workflows/ci.yml porte déjà trois jobs : secrets (Gitleaks) et sast (Semgrep) depuis le M1, et sca/dast depuis le M2. Vous ajoutez le quatrième — le seul qui regarde l'image et non les fichiers.
- Placez-vous sur une branche de fonctionnalité.
cd ~/devsecops-lab git switch main && git pull git switch -c feat/gate-image - Ajoutez le job
imagedans.github/workflows/ci.yml, au même niveau que les jobs existants — deux espaces sousjobs:.image: runs-on: ubuntu-latest needs: [ sast ] steps: - uses: actions/checkout@v4 - name: construire l'image run: docker build -t devsecops-lab:ci . - name: trivy image — gate HIGH/CRITICAL run: | docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$PWD:/work" \ aquasec/trivy:0.53.0 image \ --severity HIGH,CRITICAL \ --ignore-unfixed \ --exit-code 1 \ --format json --output /work/trivy-image.json \ devsecops-lab:ci - name: conserver le rapport meme en echec if: always() uses: actions/upload-artifact@v4 with: name: trivy-image path: trivy-image.json - Faites-le échouer d'abord. Restaurez temporairement le
Dockerfiledu M1 — celui qui part depython:3.12complet — et poussez.cp Dockerfile Dockerfile.hardened.bak # Remplacez le contenu de Dockerfile par la version du M1 : # FROM python:3.12 # WORKDIR /app # COPY . . # RUN pip install -r requirements.txt # EXPOSE 5000 # CMD ["python", "app.py"] git add .github/workflows/ci.yml Dockerfile git commit -S -m "ci: gate de scan d image (verification du rouge)" git push -u origin feat/gate-image - Ouvrez l'onglet Actions de votre dépôt. Le job
imagedoit être rouge. Ouvrez ses journaux et relevez une CVE précise qui l'a fait échouer, avec son paquet et sa version corrigée : c'est cette ligne-là qui prouve que le gate fonctionne. - Rétablissez le
Dockerfiledurci et poussez à nouveau.mv Dockerfile.hardened.bak Dockerfile git add Dockerfile git commit -S -m "ci: retour au Dockerfile durci — le gate doit passer au vert" git push - Le job passe au vert. Vous avez maintenant une preuve des deux côtés : le gate refuse ce qu'il doit refuser, et laisse passer ce qu'il doit laisser passer. Un gate testé d'un seul côté ne prouve rien.
- Ajoutez le second contrôle :
trivy configsur leDockerfileet les manifestes Kubernetes. C'est une étape à insérer dans le jobimage, après le gate d'image.- name: trivy config — Dockerfile et manifestes k8s run: | docker run --rm -v "$PWD:/src" \ aquasec/trivy:0.53.0 config \ --severity HIGH,CRITICAL --exit-code 1 /src - Ce contrôle va probablement échouer, et pour une bonne raison :
k8s/pod-root.yamlest délibérément non conforme, c'est votre manifeste de démonstration. Ne retirez pas le gate — sortez le manifeste de démonstration du périmètre, en le déplaçant dansk8s/demo/et en scannant/src/k8sà l'exclusion de ce dossier, ou en le renommant hors de l'arborescence scannée. Écrivez la justification en commentaire. - Créez le fichier d'exceptions, avec le format qui force la revue. Une ligne sans date n'a rien à y faire.
# .trivyignore — chaque ligne se justifie et porte une date de peremption # CVE-2024-XXXXX paquet non appele par l'application — revoir le 2026-11-13 CVE-2024-XXXXX exp:2026-11-13 - Vérifiez que le même workflow tourne hors ligne, sur votre poste. C'est votre repli en cas de coupure ou de quota épuisé.
# Repli hors-ligne : le meme workflow, sur votre poste, sans GitHub. act -j image --container-architecture linux/amd64 - Ouvrez la pull request et vérifiez que le job
imagefigure bien parmi les contrôles requis avant fusion.gh pr create --fill # Puis, dans Settings > Branches > main > Require status checks : # cocher « image » a cote de « secrets » et « sast ». - Consignez les deux exécutions.
cat >> journal-incidents.md <<'EOF' ## 2026-08-13 — Gate de scan d image (M3 J3) - Job `image` ajoute a ci.yml, needs: [sast] - Reglage : --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 Justification : on bloque ce qu on aurait pu corriger. Les advisories sans correctif restent documentees et motivent un changement de base. - Execution ROUGE sur Dockerfile M1 : <lien> — CVE declenchante : <id, paquet> - Execution VERTE sur Dockerfile durci : <lien> - trivy config ajoute ; k8s/pod-root.yaml sorti du perimetre (manifeste de demonstration deliberement non conforme, justifie en commentaire) - `image` ajoute aux status checks requis sur main EOF git add . && git commit -S -m "docs(M3): journal du gate d image" && git push
- Vous avez choisi
--ignore-unfixed. Formulez l'arbitrage en une phrase qu'un responsable non technique comprendrait. - Le gate empêche un build de réussir. Qu'est-ce qu'il n'empêche pas, et quel outil faudrait-il pour cela ?
- Une image validée verte aujourd'hui portera peut-être une CVE critique dans un mois. Que faut-il ajouter à la chaîne pour le détecter ?
Faites d'abord échouer, ensuite réussir. Un gate qu'on n'a jamais vu rouge n'est pas un gate : c'est une ligne de YAML dont personne ne sait si elle fonctionne. Vous construirez donc volontairement l'ancienne image en premier.
Dans un fichier de workflow, l'indentation est structurante : image: est un job, il se place au même niveau que secrets: et sast:, donc à deux espaces sous jobs:. Une erreur d'indentation ici produit un message d'erreur de GitHub à peu près illisible.
Intention — refermer la boucle du M1 (rouge → vert) sur un troisième type de contrôle, et produire le job que le PFR fera tourner de bout en bout. C'est aussi le moment où trivy config entre dans le pipeline, avec les manifestes K8s comme cible.
Ce qui coince
- Indentation du job. Erreur la plus fréquente, message le plus obscur. Faire vérifier par
act -lou par la vue Actions avant de pousser. - Le job passe vert du premier coup parce qu'il a construit l'image durcie : il n'aura rien démontré. Exiger le rouge d'abord, sur le Dockerfile du M1 restauré temporairement.
trivy configajouté avec--exit-code 1sur tout le dépôt : il remonte des constats sur les manifestes de démonstration (pod-root.yaml) et bloque légitimement. Bon problème — la réponse est de sortir les manifestes de démonstration du périmètre, pas de retirer le gate.- Minutes GitHub Actions épuisées ou réseau coupé :
actest le repli, prévu au référentiel.
Questions à anticiper
- « Pourquoi
needs: [sast]? » — pour ne pas dépenser une construction d'image quand le code est déjà refusé. Économie, et lisibilité du premier échec. - «
if: always()sur l'artefact ? » — parce que le rapport est surtout utile quand le job échoue. Détail qui fait la différence à l'usage.
Ce qu'il faut exiger — deux exécutions dans l'onglet Actions : une rouge avec la CVE qui l'a causée, une verte. Captures ou liens, dans le rapport.
TP 13 — kube-bench : lire un rapport CIS sans le prendre pour un verdict
Jour 3 · Secrets, gate d'image et audit · 45 min
Produire un rapport CIS du cluster et en trier les résultats en trois catégories défendables.
- Écrivez
k8s/kube-bench.yaml. Notez le namespace :default, et pasdso.# k8s/kube-bench.yaml — dans le namespace default, PAS dans dso apiVersion: batch/v1 kind: Job metadata: name: kube-bench namespace: default spec: backoffLimit: 0 template: spec: hostPID: true restartPolicy: Never nodeSelector: node-role.kubernetes.io/control-plane: "" tolerations: - key: node-role.kubernetes.io/control-plane operator: Exists effect: NoSchedule containers: - name: kube-bench image: aquasec/kube-bench:v0.7.3 command: ["kube-bench", "run", "--targets", "master,node,policies", "--json"] volumeMounts: - name: var-lib-etcd mountPath: /var/lib/etcd readOnly: true - name: var-lib-kubelet mountPath: /var/lib/kubelet readOnly: true - name: etc-kubernetes mountPath: /etc/kubernetes readOnly: true volumes: - name: var-lib-etcd hostPath: path: /var/lib/etcd - name: var-lib-kubelet hostPath: path: /var/lib/kubelet - name: etc-kubernetes hostPath: path: /etc/kubernetes - Avant de le lancer, faites l'expérience : tentez de le déployer dans
dsoet lisez le refus.sed 's/namespace: default/namespace: dso/' k8s/kube-bench.yaml \ | kubectl apply -f - --dry-run=server - Le refus mentionne
hostPIDet les montageshostPath. Un outil d'audit du plan de contrôle a besoin de privilèges qu'un namespace applicatif ne doit jamais accorder — c'est une observation à écrire telle quelle dans votre rapport. Elle prouve deux choses d'un coup : que PSA fonctionne, et que les outils d'infrastructure vivent ailleurs. - Lancez-le dans
defaultet récupérez le rapport.mkdir -p artefacts kubectl apply -f k8s/kube-bench.yaml kubectl wait --for=condition=complete job/kube-bench --timeout=300s kubectl logs job/kube-bench > artefacts/kube-bench-2026-08-13.json python3 - <<'EOF' import json, collections d = json.load(open("artefacts/kube-bench-2026-08-13.json")) c = collections.Counter() for ctrl in d.get("Controls", []): for g in ctrl.get("tests", []): for r in g.get("results", []): c[r["status"]] += 1 print(dict(c)) EOF - Vous obtenez un décompte du type
PASS,FAIL,WARN,INFO. Le nombre de FAIL n'est pas un score : c'est une liste à trier. - Repérez trois FAIL, un par catégorie, en cherchant leur identifiant CIS dans la sortie.
python3 - <<'EOF' import json d = json.load(open("artefacts/kube-bench-2026-08-13.json")) for ctrl in d.get("Controls", []): for g in ctrl.get("tests", []): for r in g.get("results", []): if r["status"] == "FAIL": print(r["test_number"], "|", r["test_desc"][:80]) EOF - Classez maintenant. Trois catégories, et chaque FAIL tombe dans une seule :
- Structurel à kind — le contrôle n'a pas de sens sur un cluster de développement en conteneurs. Exemple typique : les permissions de fichiers du plan de contrôle, qui vivent dans un conteneur jetable.
- Sans objet ici — le contrôle vise une fonctionnalité que vous n'utilisez pas, ou que le fournisseur gérerait sur un cluster managé.
- Réellement à corriger — le contrôle serait un défaut en production, et vous savez dire lequel. Le chiffrement des Secrets au repos, que vous avez vérifié absent ce matin, en est un excellent exemple.
- Rédigez la section 4 du rapport avec vos trois exemples.
## 4. Audit CIS et limites de la demonstration Outil : aquasec/kube-bench:v0.7.3, cible kind dso-lab (kindest/node:v1.30.0) Artefact : artefacts/kube-bench-2026-08-13.json Decompte : <n> PASS / <n> FAIL / <n> WARN Le nombre de FAIL n est pas un indicateur : kind n est pas un cluster durci. Chaque FAIL est classe dans l une des trois categories suivantes. | CIS | Constat | Categorie | Justification | |-------|---------|-----------|---------------| | <x.y> | ... | structurel a kind | ... | | <x.y> | ... | sans objet ici | ... | | <x.y> | ... | A CORRIGER | ... | Observation complementaire : le Job kube-bench est REFUSE par le namespace dso (enforce=restricted) car il exige hostPID et des montages hostPath. Deux conclusions : PSA fonctionne, et les outils d audit du plan de controle ne doivent pas vivre dans un namespace applicatif. - Nettoyez le Job et committez.
kubectl delete job kube-bench git add k8s/kube-bench.yaml rapports/durcissement-conteneurs.md git commit -S -m "docs(M3): audit CIS kube-bench, resultats tries"
- Le Job d'audit a été refusé par votre namespace applicatif. Que faut-il en conclure sur l'endroit où doivent vivre les outils d'infrastructure ?
- Un client vous demande « combien de FAIL avons-nous ? ». Comment reformulez-vous la question pour qu'elle ait un sens ?
Premier réflexe qui vous fera gagner cinq minutes : le Job kube-bench a besoin de hostPID et de montages de l'hôte. Il sera donc refusé par votre namespace dso, qui est en restricted. Ce n'est pas un problème à contourner : c'est la démonstration que PSA fonctionne. Déployez-le dans default, et notez l'observation — elle vaut un paragraphe dans votre rapport.
Second point : ne recopiez jamais un rapport CIS tel quel. Un FAIL sur un cluster de développement peut être structurel, sans objet, ou réellement grave. Le tri est tout le travail ; la liste brute n'a aucune valeur.
Intention — apprendre à traiter la sortie d'un outil d'audit : trier, justifier, ne garder que ce qui a du sens dans le contexte. Compétence directement réutilisée au M4 (registre de risques) et au PFR.
Ce qui coince
- Le refus PSA pris pour une panne. C'est au contraire l'observation la plus intéressante du TP — la souligner s'il passe à côté.
- Les 30 FAIL recopiés en annexe sans tri. Exiger exactement trois exemples développés, un par catégorie.
- Le Job ne se planifie pas :
nodeSelectoroutolerationsmal recopiés.kubectl describe podle dit clairement. kubectl logs job/kube-benchrenvoie du texte avant le JSON si l'option--jsona sauté. Vérifier la commande du conteneur.
Questions à anticiper
- « Un cluster managé donnerait-il moins de FAIL ? » — sur le plan de contrôle, beaucoup de contrôles deviennent sans objet car il n'est pas accessible. La question de la responsabilité partagée arrive juste après, en L9.
- « Faut-il corriger les FAIL sur kind ? » — non, et savoir dire pourquoi est l'objectif du TP.
Ce qu'il faut exiger — trois FAIL nommés par leur identifiant CIS, un par catégorie, avec une phrase de justification chacun.
La sécurité cloud, sans compte cloud
Jour 3 · Secrets, gate d'image et audit · 25 min
Le module s'appelle « sécurité des conteneurs et du cloud ». Il n'y a pas de compte cloud dans ce parcours — pas de carte bancaire, pas de ressource facturable, pas de dépendance à un fournisseur. Ce qui suit est donc une leçon, sans TP, sur ce qui se transfère : trois notions que vous retrouverez à l'identique chez n'importe quel fournisseur.
1. La responsabilité partagée, et ce qu'elle ne couvre pas
« Managé » ne veut pas dire « sécurisé ». La frontière est nette, et c'est toujours du même côté qu'ont lieu les incidents publics.
| Ce que le fournisseur assure | Ce qui vous reste |
|---|---|
| Le matériel, l'hyperviseur, le plan de contrôle Kubernetes | Vos droits IAM — qui peut faire quoi |
| Les correctifs du plan de contrôle et sa disponibilité | Vos groupes de sécurité et vos réseaux — qui joint quoi |
| Le chiffrement du stockage sous-jacent | Le contenu de vos seaux et leur politique d'accès |
| L'isolation entre clients | Vos images, vos manifestes, votre code — les trois jours qui précèdent |
2. IAM : la même erreur, partout
Une politique s'écrit en JSON, et voici celle qu'on trouve dans un dépôt sur deux :
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}Trois étoiles, trois problèmes : toutes les actions du service, toutes les ressources, et aucune condition. Une clé compromise portant cette politique permet de lire, modifier et supprimer l'intégralité du stockage du compte. La réécriture tient en quatre lignes : nommer les actions réellement utilisées, nommer la ressource, ajouter une condition quand elle a du sens.
C'est exactement la question de RBAC, transposée : non pas « ce compte a-t-il les droits dont il a besoin » — on répond toujours oui en élargissant — mais que peut-il faire que personne n'a demandé. Le raisonnement est identique, seule la syntaxe change.
3. Le service de métadonnées — et le lien direct avec votre SSRF
Toute machine virtuelle chez un fournisseur cloud peut interroger une adresse locale particulière pour connaître sa propre configuration :
http://169.254.169.254/latest/meta-data/Cette adresse n'est joignable que depuis l'instance elle-même. Et sur les anciennes versions du protocole, elle rend les identifiants temporaires du rôle attaché à l'instance, sans authentification, à quiconque sait faire une requête HTTP depuis la machine.
devsecops-lab porte une SSRF : la route /fetch appelle côté serveur l'URL que vous lui donnez. Sur un poste local, elle ne mène nulle part d'intéressant. Sur une instance cloud, la même faille, avec ?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/, rend des identifiants valides pour tout ce que le rôle de l'instance peut faire.
C'est le chemin d'escalade cloud le plus documenté qui soit. Vous exploiterez cette SSRF au M5 ; retenez dès maintenant qu'elle change de gravité selon l'endroit où l'application tourne.
Les parades : IMDSv2 (qui exige un jeton obtenu par PUT, ce qu'une SSRF simple ne peut pas faire), un rôle d'instance minimal, et — vous l'avez posée hier — une politique de sortie qui empêche le pod de joindre cette adresse.
Ce qui reste vrai partout
- Un seau public n'est presque jamais une décision. C'est un
Allowsur un principal générique, écrit un vendredi pour débloquer quelqu'un.trivy configdétecte ce motif dans du Terraform, exactement comme il détecte un conteneur root dans votre Dockerfile. - Les clés statiques n'ont pas leur place. Rôles à durée limitée, fédération d'identité — jamais une clé permanente dans une variable d'environnement de CI.
- Le journal d'audit du fournisseur est la seule preuve. Comme votre
journal d'apiserver : et vous avez vu ce matin, en lisant
etcddirectement, ce que vaut un journal qu'on peut contourner.
Prowler, ScoutSuite, CloudSploit auditent un compte live : sans compte, ils n'ont rien à auditer. Checkov et tfsec analysent le Terraform avant déploiement — leur rôle est tenu ici par trivy config, que vous avez branché ce matin dans le pipeline. C'est un choix d'outillage, pas une lacune : sachez le défendre.
Intention — couvrir honnêtement le volet « cloud » du module sans prétendre l'avoir manipulé. Trois notions transférables : responsabilité partagée, IAM de moindre privilège, service de métadonnées. La troisième relie directement VULN-08 au M5 — c'est elle qui doit rester.
Ce qui coince
- « Managé donc sécurisé. » Le fournisseur sécurise le plan de contrôle ; les droits IAM, les groupes de sécurité et le contenu des seaux restent au client — et c'est là que se produisent les incidents publics.
- Le service de métadonnées jugé anecdotique. C'est le mécanisme central de l'escalade cloud : une SSRF vers
169.254.169.254rend des identifiants temporaires valides. À relier explicitement àVULN-08. "Action": "s3:*"jugé acceptable « parce que c'est notre compte ». C'est exactement la politique qu'on retrouve dans les post-mortems.
Questions à anticiper
- « On ne verra jamais AWS de la formation ? » — non, et c'est assumé : pas de compte, pas de carte bancaire, pas de ressource facturable. Ce qui se transfère, ce sont les principes et la lecture d'une politique.
- « Prowler, ScoutSuite ? » — auditent un compte live. Sans compte, ils n'ont rien à auditer. Les citer, ne pas les installer.
Ce qu'il faut exiger — qu'il réécrive la politique large en politique restreinte, à la main, et qu'il explique le chemin SSRF → métadonnées → identifiants en trois phrases.
TP 14 — Clore le dossier de durcissement
Jour 3 · Secrets, gate d'image et audit · 45 min
Terminer les sections 3 et 5 du rapport, et rendre le module opposable : chaque affirmation adossée à une preuve, chaque limite énoncée.
- Section 3 — les secrets. Reprenez le tableau des quatre chemins et son verdict, sans l'édulcorer.
## 3. Secrets Un Secret Kubernetes est encode en base64, pas chiffre. Verifie en direct : kubectl -n dso get secret lab-secrets -o jsonpath='{.data.API_KEY}' | base64 -d | Chemin de lecture | Etat final | Ce qui le ferme | |---------------------------|-------------------|------------------------| | API (get secrets) | ferme pour lab-sa | RBAC | | Variables d environnement | supprime | montage fichier | | Fichier monte | 0400, readOnly | defaultMode | | etcd en clair | OUVERT | chiffrement at-rest | Le dernier chemin reste ouvert et ne peut pas etre ferme dans ce contexte : l apiserver de dso-lab n a pas de --encryption-provider-config (verifie). Consequence : toute sauvegarde d etcd contient les secrets en clair. - Section 5, première partie — ce qui n'est pas démontrable sur un poste. Ce n'est pas un aveu de faiblesse : c'est de la précision.
## 5. Ce qui n a pas ete fait, et pourquoi ### 5.1 — Non demontrable sur un poste, traite en theorie - Chiffrement des Secrets au repos par un KMS : exige un service de gestion de cles. Demontre par la negative (absence de --encryption-provider-config). - Journal d audit complet de l apiserver : exige une politique d audit et un collecteur. Non monte. - Reseau multi-machines : les deux noeuds kind sont des conteneurs du meme hote. Les NetworkPolicy sont bien appliquees par Calico, mais le chemin physique n est pas represente. - IAM d un fournisseur cloud : aucun compte dans ce parcours. Traite en lecon (moindre privilege, service de metadonnees, responsabilite partagee). - Section 5, seconde partie — ce qui a été choisi. La différence avec la précédente est essentielle : ici, vous avez arbitré.
### 5.2 — Ecarte volontairement, avec le motif - Image distroless : surface plus faible, mais plus de shell donc plus de diagnostic possible dans le cluster. Arbitrage rendu en faveur de python:3.12-slim pour la duree de la formation. - Epinglage par digest : ecarte pour que les reconstructions suivent les correctifs. A rendre obligatoire avant toute mise en production. - Kyverno / Gatekeeper (blocage a l admission sur l image et sa signature) : le gate agit au build, pas au deploiement. Ecart assume et documente. - SonarQube, Snyk, Checkov, Prowler : roles couverts par Semgrep, Trivy et trivy config. Choix d outillage, pas lacune. - Écrivez maintenant le résumé, en tête du document. Dix lignes, sans jargon, lisibles à voix haute en deux minutes.
## Resume L application devsecops-lab a ete durcie a trois niveaux entre le 11 et le 13 aout 2026, sur un poste de travail, sans aucune ressource externe. Image : passage a une construction multi-stage sur base slim, execution en utilisateur 10001, exclusion de .env et .git. <n> vulnerabilites HIGH et CRITICAL en moins, mesurees le meme jour avec le meme outil. Cluster : trois garde-fous poses et chacun demontre par un refus observe — RBAC refuse la lecture des secrets, PodSecurityAdmission refuse un pod root, NetworkPolicy bloque un flux entre pods. Chaine : le scan d image est desormais bloquant dans le pipeline, verifie rouge puis vert. Une limite subsiste et n est pas contournable ici : les Secrets sont ecrits en clair dans etcd. Elle est documentee en section 5.1. - Vérifiez que chaque artefact cité existe et porte le nom attendu.
ls -1 artefacts/ # Attendu : # trivy-image-dev-2026-08-11.json # kube-bench-2026-08-13.json ls -1 k8s/ # Attendu : deployment.yaml rbac.yaml netpol.yaml netpol-allow.yaml # netpol-egress.yaml psa-namespace.yaml kube-bench.yaml attaquant.yaml git status --short - Committez, poussez, et fusionnez vos branches du module.
git add rapports/ journal-incidents.md git commit -S -m "docs(M3): dossier de durcissement complet, limites incluses" git push # Puis fusionnez feat/gate-image apres passage au vert du pipeline.
- Un jury vous demande : « qu'est-ce qui reste vulnérable ? ». Répondez en trente secondes, sans lire vos notes.
- Vous avez distingué ce qui n'est pas démontrable de ce que vous avez écarté. Pourquoi cette distinction change-t-elle la crédibilité du document ?
Intention — produire le livrable que le PFR reprendra, et surtout faire écrire la section 5. Un dossier de durcissement qui ne dit pas ce qu'il n'a pas fait est un communiqué, pas un audit — et c'est la section qu'un jury lit en premier.
Ce qui coince
- Section 5 réduite à « manque de temps ». Exiger la distinction entre non démontrable sur un poste (chiffrement at-rest par KMS, audit apiserver, réseau multi-machines, IAM cloud) et choisi (distroless, épinglage par digest, Kyverno).
- Le résumé écrit en dernier et bâclé. C'est pourtant le seul paragraphe que certains liront.
- Les artefacts cités sans nom de fichier. Le nommage du référentiel existe pour ça.
Questions à anticiper
- « Je mets les captures d'écran ? » — les liens des exécutions Actions suffisent, et vieillissent mieux.
Ce qu'il faut exiger — une section 5 avec deux sous-parties distinctes, et un résumé de dix lignes qu'il puisse lire à voix haute en deux minutes.
M3 · J3 — Secret lu en clair dans etcd, gate d'image bloquante, refus rejoués
Jour 3 · Secrets, gate d'image et audit · 2 h
Elle a lieu jeudi à 15 h 00 et dure deux heures — c'est la durée portée par ce point de contrôle. Votre encadrant ne vous demandera pas de raconter ce que vous avez fait : il vous demandera de le refaire devant lui, en commençant par les deux refus qui donnent son nom à ce point.
Préparez donc, avant 15 h : le cluster en marche (kubectl get nodes → deux Ready), le pod attaquant prêt, un terminal à la racine du dépôt, et l'onglet Actions ouvert sur les deux exécutions du job image.
Ce qui n'est pas attendu : que tout soit fermé. Un module de durcissement honnête laisse des choses ouvertes et sait dire lesquelles. La section 5 de votre rapport vaut autant que les quatre autres.
Après la revue, vous pouvez arrêter le cluster (docker stop dso-lab-control-plane dso-lab-worker) sans le détruire : le projet fil rouge le réutilisera le 21 août.
Intention — revue de fin de module, deux heures. Elle ne se raconte pas, elle se démontre : les deux refus obtenus mercredi (PSA, NetworkPolicy) sont rejoués en direct, sur le cluster, devant vous. Tout le reste — image, secrets, gate, rapport — se vérifie autour de ces deux gestes.
Déroulé proposé (2 h)
- 0–15 min — il ouvre « Mon avancement », résume le module en trois phrases, annonce ce qu'il va montrer.
- 15–45 min — les deux refus rejoués, par lui, sans notes :
kubectl apply -f k8s/pod-root.yamlrefusé et commenté ; lecurlbloqué puis autorisé par l'étiquette. Puiscan-iet le décodage d'un Secret. - 45–75 min — l'image :
id,ls -a /app, les deux comptages Trivy, la ligne deapp.pyqui fermeVULN-09. - 75–100 min — le pipeline : les deux exécutions, rouge et verte, avec la CVE déclenchante ; le job dans les contrôles requis.
- 100–120 min — le rapport, et surtout la section 5. Vos questions.
Ce qu'il faut exiger — que les deux refus soient produits en direct. Une capture d'écran de mercredi ne remplace pas la commande tapée maintenant : c'est précisément ce que le titre de ce point annonce.
Ce qui coince, à cette revue précisément
- Cluster détruit ou arrêté depuis mercredi. Si les conteneurs sont juste arrêtés,
docker startet une minute suffisent ; s'il l'a supprimé, la revue se fait sur les artefacts et le module n'est pas validé en l'état. - Il récite les cinq violations de PSA sans savoir laquelle correspond à quelle ligne de manifeste. Demander la correspondance sur deux d'entre elles.
- Section 5 absente ou réduite au manque de temps. Point bloquant : c'est ce qui distingue un audit d'une plaquette.
- Il ne sait pas dire pourquoi Calico a remplacé kindnet. Sans cela, sa démonstration réseau ne prouve rien, et il ne le sait pas.
Questions de fond à poser
- « Quel geste a le plus fait baisser le compteur de CVE, quel geste a le plus réduit le risque ? »
- « Un pod tourne déjà en root dans un namespace que vous étiquetez
restricted. Que se passe-t-il, et que faites-vous ? » - « Votre gate bloque un build. Empêche-t-il un déploiement ? »
- « Quel chemin de lecture d'un Secret n'avez-vous pas pu fermer, et qu'auriez-vous fait sur un vrai cluster ? »
Ne pas valider si — l'un des deux refus (PSA, NetworkPolicy) n'est pas reproduit en direct, ou si la section 5 du rapport n'existe pas. Le reste peut se rattraper ; ces deux points-là sont le module.