hors suivi
Leçon

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

Machine virtuelle Matériel Système hôte + hyperviseur VM 1 noyau propre libs + appli VM 2 noyau propre libs + appli Deux noyaux : sortir de l'un ne donne pas l'autre Conteneurs Matériel UN SEUL noyau — partagé par tous conteneur libs appli conteneur libs appli conteneur libs appli Un seul noyau : une faille noyau traverse tous les conteneurs namespaces = ce qu'on voit · cgroups = ce qu'on consomme · capabilities = ce qu'on peut
Un conteneur n'est pas une petite machine virtuelle : c'est un processus de l'hôte à qui l'on a restreint la vue.

Les trois mécanismes, et ce que chacun fait

MécanismeCe qu'il règleExemple concret
namespacesCe que le processus voitLe conteneur ne voit que ses propres processus, ses propres montages, sa propre pile réseau
cgroupsCe que le processus consommePlafond mémoire, part de CPU, débit disque
capabilitiesCe que le processus peutCAP_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.

La conséquence, et c'est celle qui compte

Ê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.
Et sous macOS ou Windows ?

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.

✓ terminé
Travaux pratiques

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.

Périmètre — rappel

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.

  1. 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
  2. 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
  3. 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'
  4. 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'
  5. 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
  6. Vous venez de lister, depuis un conteneur, tous les conteneurs de l'hôte. Notez la conclusion : monter /var/run/docker.sock équivaut à donner le root de l'hôte. Vous le referez au TP 3 pour Trivy — en connaissance de cause, et sur votre poste seulement.
  7. 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
Questions de réflexion — à préparer par écrit
  • 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.sock dans 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 ?
Pour vous repérer

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.

✓ terminé
Leçon

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.

FROM python:3.12 — 1,02 Go COPY . . — 4,1 Mo (dont .env et .git) RUN pip install — 62 Mo CMD — 0 o (métadonnée) couche inscriptible du conteneur Lecture seule et empilées : une couche ne retire jamais ce qu'une autre a écrit — elle le masque. construite du bas vers le haut
Une image est une pile de couches en lecture seule. Ce qui entre dans une couche y reste, même si une instruction suivante le supprime.

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.

Le Dockerfile qui a l'air prudent et qui ne l'est pas

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

IdentifiantCe qu'il désigneStable ?
Tagpython:3.12Une étiquette mobile, repointée par le publieurNon : le même tag désigne un contenu différent la semaine suivante
Digestsha256:…Le condensat du manifeste, donc du contenu exactOui : un digest désigne un seul contenu, pour toujours
Image IDsha256:…Le condensat de la configuration locale, après téléchargementLocal, 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

CommandeCe 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.tarL'image entière sur disque, couche par couche, ouvrable avec tar
docker run --rm <image> ls -a /appLe résultat empilé, tel que le conteneur le voit — pas le contenu des couches
La distinction à ne pas perdre

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.

✓ terminé
Travaux pratiques

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.

Point de départ

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.

  1. Depuis la racine de devsecops-lab, reconstruisez l'image de développement et relisez le Dockerfile du M1 : c'est lui que vous auditez.
    cd ~/devsecops-lab
    cat Dockerfile
    docker build -t devsecops-lab:dev .
  2. 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
  3. Vérifiez la vue aplatie, celle que l'application voit. Vous devez y trouver .env et .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'
  4. Arrêtez-vous une seconde sur ce que vous venez d'afficher. .env contient le faux secret posé au M1 ; .git contient l'historique complet du dépôt, y compris les commits antérieurs à la correction de VULN-05. Corriger un secret dans le code ne le retire pas de l'historique, et l'historique est dans l'image.
  5. 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 .
  6. 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
  7. Ouvrez maintenant l'image couche par couche. Vous cherchez deux entrées : tmp/secret.txt dans une couche, et tmp/.wh.secret.txt dans 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
  8. Extrayez le contenu de la couche qui porte tmp/secret.txt et 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
  9. motdepasse=hunter2 s'affiche. Le fichier a été supprimé dans le Dockerfile, 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.
  10. 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
Questions de réflexion — à préparer par écrit
  • Un collègue propose de résoudre le problème en ajoutant RUN rm -rf /app/.git à la fin du Dockerfile. 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 ?
Pour vous repérer

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.

✓ terminé
Quiz

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 ?

✓ terminé
Travaux pratiques

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.

Ce que vous produisez

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.

  1. 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-cache fait 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
  2. 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.
  3. 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
  4. 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.
  5. Passez au second moteur. trivy config ne regarde pas les paquets installés mais les fichiers de configuration : votre Dockerfile, 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
  6. Vous devez y lire au moins deux constats : l'image tourne en root (aucune instruction USER) 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.
  7. 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
  8. 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
Questions de réflexion — à préparer par écrit
  • Pourquoi vos dépendances Python, corrigées au M2, ne suffisent-elles pas à faire baisser le total du scan d'image ?
  • trivy image et trivy config ne 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 ?
Pour vous repérer

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.

✓ terminé
Leçon

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

GesteCe qu'il supprimeEffet typique sur le scan
1. Changer d'image de base
python:3.12python:3.12-slim
Plusieurs centaines de paquets Debian jamais appelés : compilateurs, en-têtes, outilsLa 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 finaleCe qui reste après le point 1, quand il faut compiler
3. Ne pas tourner en root
USER 10001
Rien dans le scan — mais tout dans l'impact d'une compromissionZé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, rapportsZéro CVE en moins, un secret et un historique en moins
Les gestes 3 et 4 ne font pas baisser le compteur

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.

Ce qu'on cite sans l'adopter

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 le COPY, 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 une readinessProbe.
  • Épingler par digestFROM 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.
Pour vous repérer

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.

✓ terminé
Travaux pratiques

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.

Une branche, comme d'habitude

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.

  1. Créez la branche de correction.
    cd ~/devsecops-lab
    git switch -c fix/vuln-09-debug
  2. Écrivez le .dockerignore. Il agit avant le COPY : c'est la seule façon d'empêcher un fichier d'entrer dans une couche.
    .git
    .env
    artefacts/
    rapports/
    __pycache__/
    *.pyc
    k8s/
  3. Remplacez le contenu de Dockerfile par la version multi-stage. Un seul fichier, deux FROM. Notez l'USER numé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"]
  4. 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
  5. Vous lisez sqlite3.OperationalError: unable to open database file. Diagnostic : WORKDIR /app a créé le répertoire au nom de root ; l'utilisateur 10001 n'a pas le droit d'y créer lab.db. C'est le prix normal du passage en non-root, et il se paie une fois. Ne repassez pas en root : donnez la propriété du répertoire.
  6. 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
  7. 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
  8. Fermez maintenant VULN-09 dans 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")
  9. 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'
  10. 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
  11. 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
  12. 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
  13. 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
Questions de réflexion — à préparer par écrit
  • 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 ?
Pour vous repérer

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.

✓ terminé
Travaux pratiques

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.

  1. 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
  2. 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.
  3. 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
  4. 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     |
  5. 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.
  6. 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.
  7. 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
Questions de réflexion — à préparer par écrit
  • 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 ?
Pour vous repérer

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.

✓ terminé
Point de contrôle

M3 · J1 — Image durcie : non-root, multi-stage, gain mesuré

Jour 1 · L'image : ce qu'elle contient vraiment · 30 min

Vérifiez avant de continuer
Ce que vous montrez au point de 16 h 30

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.

✓ terminé
Leçon

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.

kubectl apply -f Authentification qui êtes-vous ? RBAC avez-vous le droit ? Admission (PSA) l'objet est-il licite ? etcd écrit, puis planifié refus 403 refus à l'admission La NetworkPolicy n'intervient pas ici : elle agit après, sur le trafic entre pods.
Trois portes successives, trois questions différentes. Un refus n'a pas le même sens selon la porte qui l'a prononcé.
PorteQuestion tranchéeRefus typique
AuthentificationQui êtes-vous ?Unauthorized — certificat ou jeton invalide
Autorisation — RBACAvez-vous le droit de faire cette action sur cet objet ?Error from server (Forbidden): … is forbidden: User … cannot get resource "secrets"
Admission — PSAL'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.

Le CNI décide si vos NetworkPolicy servent à quelque chose

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
NamespaceLe périmètre dans lequel s'appliquent Role, PSA et NetworkPolicy. Sans eux, ce n'est qu'un préfixe de nom.
ServiceAccountL'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 / RoleBindingUn ensemble de droits dans un namespace, et l'attribution de cet ensemble à une identité.
securityContextLes privilèges demandés par le conteneur. C'est ce que PSA lit pour accepter ou refuser.
NetworkPolicyUne liste blanche de flux. Dès qu'un pod est visé par une politique, tout ce qui n'y figure pas est refusé.
Ce que le cluster local démontre, et ce qu'il ne démontre pas

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.

✓ terminé
Travaux pratiques

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.

Prérequis et budget

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.

  1. 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 ---
  2. Voici le contenu de kind-config.yaml. disableDefaultCNI: true est la ligne décisive : elle retire kindnet, 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
  3. 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
  4. 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
  5. Les deux nœuds doivent maintenant afficher Ready. Si ce n'est pas le cas après cinq minutes, regardez kubectl -n kube-system get pods : un pod calico-node en ImagePullBackOff est un problème de réseau sortant, pas de configuration.
  6. 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
  7. É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
  8. 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
  9. Vous devez lire devsecops-lab — cible d'entrainement locale. Si le pod est en CrashLoopBackOff, regardez d'abord kubectl -n dso logs deploy/devsecops-lab : une erreur SQLite signifie que vous avez chargé une image construite avant le chown d'hier.
  10. 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"
Questions de réflexion — à préparer par écrit
  • disableDefaultCNI: true rend 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 complet devsecops-lab.dso.svc.cluster.local ?
Pour vous repérer

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.

✓ terminé
Leçon

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/token

Consé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

ObjetPortéeCe qu'il déclare
ServiceAccountNamespaceUne identité, attribuée à des pods
RoleNamespaceUne liste de droits : ressources × verbes
RoleBindingNamespaceQui obtient quel Role
ClusterRole / ClusterRoleBindingCluster entierLes mêmes, sans frontière de namespace — à manier avec précaution
Le point qui piège une fois, puis plus jamais

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

ÉcritureCe qu'elle accorde réellementVerdict
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'autreAcceptable — 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 dso

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

Le geste qu'on oublie toujours

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.

Pour vous repérer

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.

✓ terminé
Travaux pratiques

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.

Ce que vous allez faire, et sur quoi

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.

  1. É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
  2. É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"]
  3. 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
  4. 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
  5. Les deux premières commandes répondent yes, la troisième no : le Role est limité au namespace dso. C'est la seule bonne nouvelle de cette configuration.
  6. 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
  7. La réponse est un JSON contenant lab-secrets et ses valeurs. Vous venez d'exfiltrer les secrets du namespace depuis un conteneur applicatif, sans exploiter la moindre faille du code. Sortez du pod avec exit.
  8. 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
  9. Appliquez, et recréez le pod. Ce second point est essentiel : automountServiceAccountToken n'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
  10. 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'
  11. 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"
Questions de réflexion — à préparer par écrit
  • 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: false supprime 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.
Pour vous repérer

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.

✓ terminé
Quiz

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 ?

✓ terminé
Leçon

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 :

NiveauCe qu'il autorise
privilegedTout. C'est l'absence de contrainte — le défaut d'un namespace non étiqueté.
baselineInterdit les privilèges les plus évidents : privileged, hostNetwork, hostPID, montages hôte.
restrictedImpose 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: restricted

Les 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 rootrunAsNonRoot: true
Pas d'élévation de privilègeallowPrivilegeEscalation: false
Aucune capabilitycapabilities: drop: ["ALL"]
Profil seccomp expliciteseccompProfile: type: RuntimeDefault
Pas de conteneur privilégiéprivileged absent ou false
Deux refus possibles, à deux moments différents

À 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.
✓ terminé
Travaux pratiques

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.

  1. 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
  2. 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
  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.
  4. Écrivez k8s/psa-namespace.yaml et appliquez-le. enforce refuse, warn pré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
  5. 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
  6. Rejouez le pod root. Cette fois, il ne sera pas écrit du tout.
    kubectl apply -f k8s/pod-root.yaml
  7. 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.
  8. Prenez l'habitude de tester avant d'appliquer. --dry-run=server passe par l'admission sans rien écrire ; --dry-run=client ne teste rien du tout.
    kubectl apply -f k8s/pod-root.yaml --dry-run=server
    kubectl apply -f k8s/deployment.yaml --dry-run=server
  9. Votre déploiement applicatif est refusé lui aussi. Rendez-le conforme : ajoutez le securityContext, et prévoyez un volume inscriptible puisque readOnlyRootFilesystem interdit 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: {}
  10. 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
  11. 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"'
  12. Rendez enfin le pod attaquant conforme — vous en aurez besoin au TP suivant, et il est actuellement refusé lui aussi. Réécrivez k8s/attaquant.yaml en 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
  13. 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
  14. 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"
Questions de réflexion — à préparer par écrit
  • 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: true a 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 ?
Pour vous repérer

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.

✓ terminé
Travaux pratiques

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.

Vérifiez Calico avant de commencer

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.

  1. 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/
  2. 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.
  3. É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 ]
  4. 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/
  5. Le curl expire au bout de cinq secondes : plus aucun flux entrant n'atteint les pods de dso. 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é.
  6. Ouvrez maintenant le seul flux légitime. Attention aux deux podSelector : celui du haut désigne qui est protégé, celui du bloc from dé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
  7. Appliquez, et rejouez la mesure. Elle doit toujours échouer : le pod attaquant ne porte pas l'étiquette role: 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/
  8. 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/
  9. 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.
  10. 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=$?"
  11. 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.
  12. É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
  13. 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'
  14. Retenez ce dernier résultat : devsecops-lab porte 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.
  15. 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"
Questions de réflexion — à préparer par écrit
  • 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.
Pour vous repérer

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.

✓ terminé
Travaux pratiques

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.

  1. 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.
  2. 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 : ...
  3. Bloc 1 — RBAC. Les deux sorties de can-i et 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.
  4. 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.
  5. 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.
  6. 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.
  7. Committez.
    git add rapports/durcissement-conteneurs.md
    git commit -S -m "docs(M3): section 2, les trois refus demontres"
    git push
Questions de réflexion — à préparer par écrit
  • 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 ?
✓ terminé
Point de contrôle

M3 · J2 — Trois refus obtenus : RBAC, PSA, NetworkPolicy

Jour 2 · Kubernetes : ce que chaque garde-fou empêche · 30 min

Vérifiez avant de continuer
Ce que vous montrez au point de 16 h 30

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.

✓ terminé
Leçon

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

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

La différence, en une phrase

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

QuiCommentCe qui limite
Un compte avec get secretskubectl get secret -o yaml puis base64 -dRBAC — c'est le levier principal, celui de votre TP d'hier
Un pod qui monte le SecretLecture du fichier, ou de /proc/1/environ si c'est une variableN'en monter que ce dont le pod a besoin, et jamais en variable
Quiconque lit etcdetcdctl get /registry/secrets/<ns>/<nom>Le chiffrement at-rest — absent par défaut
Quiconque a une sauvegarde d'etcdLe même contenu, hors ligne, sans aucune traceChiffrer les sauvegardes, et les traiter comme des secrets
Le point que la démonstration de tout à l'heure va rendre concret

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'environnementVolume monté
Visible dans /proc/<pid>/environOuiNon
Récupérée par une trace de plantage ou un outil d'observabilitéSouventNon
Héritée par les processus enfantsOuiNon
Mise à jour sans redémarrer le podNonOui, le kubelet rafraîchit le fichier
Permissions réglablesNonOuidefaultMode: 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-restEncryptionConfiguration avec aescbc ou un KMS externe. C'est la vraie réponse au chemin etcd.
  • 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.
Pourquoi rien de tout cela n'est déployé dans ce parcours

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.

Pour vous repérer

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.

✓ terminé
Travaux pratiques

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.

Reprise du cluster

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.

  1. 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
  2. Vous lisez AKIAIOSFODNN7EXAMPLE en clair. Une commande, aucune clé, aucun privilège particulier au-delà de get secrets. C'est ce que RBAC protège — et rien d'autre ne le protège.
  3. 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
  4. 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 — et describe révèle au moins le nom des variables, ce qui suffit à orienter une attaque.
  5. 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
  6. 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.
  7. 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" 
  8. 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
  9. 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'
  10. 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
  11. 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"
Questions de réflexion — à préparer par écrit
  • 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 ?
Pour vous repérer

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.

✓ terminé
Quiz

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 ?

✓ terminé
Leçon

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:hardened

Un 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églageEffetTenable ?
--severity LOW,MEDIUM,HIGH,CRITICALRouge en permanence, y compris sur des paquets sans correctifNon — sera contourné
--severity HIGH,CRITICAL seulRouge sur des advisories que personne ne peut corriger aujourd'huiDifficilement
HIGH,CRITICAL + --ignore-unfixedRouge 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.

Ce qu'un fichier d'exceptions doit contenir

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

NiveauCe qu'il empêcheOutil
BuildQue l'image soit produite ou publiéetrivy image --exit-code 1 dans le pipeline — c'est ce que vous faites aujourd'hui
AdmissionQu'une image non conforme soit déployée dans le clusterKyverno, Gatekeeper, vérification de signature (cosign) — hors programme
ExécutionQu'un comportement anormal se poursuiveFalco, 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.

Le scan du jour J ne dit rien du jour J+30

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.

✓ terminé
Travaux pratiques

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.

État du pipeline avant ce TP

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.

  1. Placez-vous sur une branche de fonctionnalité.
    cd ~/devsecops-lab
    git switch main && git pull
    git switch -c feat/gate-image
  2. Ajoutez le job image dans .github/workflows/ci.yml, au même niveau que les jobs existants — deux espaces sous jobs:.
      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
  3. Faites-le échouer d'abord. Restaurez temporairement le Dockerfile du M1 — celui qui part de python:3.12 complet — 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
  4. Ouvrez l'onglet Actions de votre dépôt. Le job image doit ê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.
  5. Rétablissez le Dockerfile durci 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
  6. 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.
  7. Ajoutez le second contrôle : trivy config sur le Dockerfile et les manifestes Kubernetes. C'est une étape à insérer dans le job image, 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
  8. Ce contrôle va probablement échouer, et pour une bonne raison : k8s/pod-root.yaml est 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 dans k8s/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.
  9. 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
  10. 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
  11. Ouvrez la pull request et vérifiez que le job image figure 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 ».
  12. 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
Questions de réflexion — à préparer par écrit
  • 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 ?
Pour vous repérer

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.

✓ terminé
Travaux pratiques

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.

  1. Écrivez k8s/kube-bench.yaml. Notez le namespace : default, et pas dso.
    # 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
  2. Avant de le lancer, faites l'expérience : tentez de le déployer dans dso et lisez le refus.
    sed 's/namespace: default/namespace: dso/' k8s/kube-bench.yaml \
      | kubectl apply -f - --dry-run=server
  3. Le refus mentionne hostPID et les montages hostPath. 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.
  4. Lancez-le dans default et 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
  5. 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.
  6. 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
  7. 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.
  8. 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.
  9. 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"
Questions de réflexion — à préparer par écrit
  • 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 ?
Pour vous repérer

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.

✓ terminé
Leçon

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 assureCe qui vous reste
Le matériel, l'hyperviseur, le plan de contrôle KubernetesVos 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-jacentLe contenu de vos seaux et leur politique d'accès
L'isolation entre clientsVos 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.

Le réflexe est celui de mardi et mercredi

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.

Reliez cela à VULN-08

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 Allow sur un principal générique, écrit un vendredi pour débloquer quelqu'un. trivy config dé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 etcd directement, ce que vaut un journal qu'on peut contourner.
Les outils qu'on cite sans les installer

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.

✓ terminé
Travaux pratiques

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.

  1. 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.
  2. 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).
  3. 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.
  4. É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.
  5. 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
  6. 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.
Questions de réflexion — à préparer par écrit
  • 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 ?
✓ terminé
Point de contrôle

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

Vérifiez avant de continuer
La revue de fin de module

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.

✓ terminé

Mon avancement

Signature d'émargement

Demander de l'aide à votre encadrant

Étape concernée :

Avant d'envoyer

Cherchez seul pendant 30 minutes et notez ce que vous avez tenté. Ce que vous avez essayé fait partie de ce que vous présentez — c'est ce qui distingue l'autonomie de l'abandon.