Les secrets pendant la construction
Pourquoi
Beaucoup de constructions ont besoin d'un secret pendant qu'elles s'exécutent : un jeton pour télécharger les bibliothèques internes d'un dépôt privé (PyPI, npm, Maven), une clé SSH pour cloner un dépôt Git privé, un identifiant pour une API qui fournit des données de référence. Ces secrets sont nécessaires quelques secondes, dans une instruction RUN, et ne doivent jamais atterrir dans l'image : elle sera poussée dans un registre, téléchargée par des serveurs, parfois par des prestataires, et elle vivra des années.
Le cours précédent a traité des secrets à l'exécution (leçons 5 et 11 de Docker : les fondamentaux) et montré qu'un fichier supprimé reste dans sa couche (leçon 7, exercice 4). Cette leçon traite de la construction. Elle commence par fabriquer, volontairement, les trois fuites que l'on rencontre dans les vrais dépôts, puis montre les mécanismes que BuildKit prévoit pour qu'elles n'arrivent pas.
Le scénario
Lyneko publie ses bibliothèques Python internes sur un dépôt PyPI privé, protégé par un identifiant et un mot de passe. Signalements doit désormais dépendre de l'une d'elles, lyneko-outils, qui normalise les noms de voies :
flask==3.1.3
gunicorn==26.2.0
psycopg[binary]==3.3.6
lyneko-outils==1.0.0Pour l'exercice, le dépôt privé est un serveur pypiserver local, publié sur le port 10450 de la machine, avec un compte ci-lyneko :
$ curl -s -o /dev/null -w "sans identifiants : %{http_code}\n" http://127.0.0.1:10450/simple/lyneko-outils/
sans identifiants : 401
$ curl -s -o /dev/null -w "avec identifiants : %{http_code}\n" -u 'ci-lyneko:S3cret-Depot-2026' http://127.0.0.1:10450/simple/lyneko-outils/
avec identifiants : 200
pip sait chercher dans un dépôt supplémentaire grâce à l'option extra-index-url, qui accepte des identifiants dans l'URL : http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/. Toute la question est de lui transmettre cette URL.
Note
Les constructions de cette leçon utilisent --network host pour que les instructions RUN puissent joindre le dépôt publié sur 127.0.0.1. En situation réelle, le dépôt privé a un nom DNS et ce réglage est inutile.
Les concepts
Où une construction laisse des traces
Une construction produit bien plus que des couches. Chacun de ces éléments peut contenir un secret :
| Trace | Ce qu'elle contient | Qui y a accès |
|---|---|---|
| Les couches | Tous les fichiers ajoutés à chaque étape, y compris ceux supprimés plus tard | Quiconque peut télécharger l'image |
| La configuration de l'image | Les variables ENV, l'utilisateur, la commande | Idem, et docker inspect |
| L'historique | Le texte de chaque instruction, avec la valeur des ARG déclarés avant chaque RUN | Idem, et docker history |
| L'attestation de provenance | Les paramètres de la construction, dont les arguments (--build-arg) | Quiconque peut lire l'image dans le registre |
| Le cache de construction | Les couches des étapes intermédiaires | Quiconque accède au builder ou au cache exporté |
| Les journaux de la CI | Tout ce que les commandes affichent | Quiconque lit les journaux de la CI |
Ce que propose BuildKit
BuildKit transmet les secrets à côté de la construction, jamais dans ses entrées :
--secret id=...,src=fichier(ouenv=VARIABLE) au lancement, etRUN --mount=type=secret,id=...dans le Dockerfile : le secret est monté dans un système de fichiers temporaire (par défaut/run/secrets/<id>, ou à l'emplacement choisi avectarget=), ou exposé comme variable d'environnement avecenv=, pendant cette seule instruction ;--ssh defaultetRUN --mount=type=ssh: la construction reçoit un accès à votre agent SSH (le socket), pas la clé privée elle-même.
Dans les deux cas, le secret n'entre ni dans les couches, ni dans l'historique, ni dans la clé de cache, ni dans l'attestation de provenance, qui n'enregistre que son identifiant.
En pratique
Fuite n° 1 : par ARG
C'est la solution la plus répandue, parce qu'elle tient en une ligne : un argument de construction, que pip lit directement comme variable d'environnement (PIP_EXTRA_INDEX_URL).
# syntax=docker/dockerfile:1
FROM python:3.14-slim
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_ROOT_USER_ACTION=ignore
WORKDIR /app
COPY requirements.txt .
ARG PIP_EXTRA_INDEX_URL
RUN pip install -r requirements.txt
COPY app.py .
USER 10001
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--no-control-socket", "app:app"]$ URL='http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/'
$ docker build --network host -q -t signalements:arg -f Dockerfile.arg --build-arg PIP_EXTRA_INDEX_URL=$URL .
sha256:f85112e6a92326854f72173ac0754a5cb57142d40655248d2f1e7cc6f0acdeb5
$ docker run --rm signalements:arg python -c "import lyneko_outils; print(lyneko_outils.normaliser_lieu(' rue des lilas '))"
Rue des lilas
La bibliothèque privée est installée. Le mot de passe, lui, n'apparaît pas dans les variables d'environnement de la configuration (Config.Env : un ARG n'est pas un ENV) :
$ docker image inspect signalements:arg --format '{{json .Config.Env}}'
["PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","PYTHON_VERSION=3.14.7","PYTHON_SHA256=3b48dac8fb59f62eaa67ac83c1eb12bda1b7a08406dd286e252c11a66be27f81","PIP_NO_CACHE_DIR=1","PIP_DISABLE_PIP_VERSION_CHECK=1","PIP_ROOT_USER_ACTION=ignore"]
Mais il est dans l'historique, deux fois :
$ docker history signalements:arg --no-trunc --format '{{.CreatedBy}}' | head -5
CMD ["gunicorn" "--bind" "0.0.0.0:8000" "--no-control-socket" "app:app"]
USER 10001
COPY app.py . # buildkit
RUN |1 PIP_EXTRA_INDEX_URL=http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/ /bin/sh -c pip install -r requirements.txt # buildkit
ARG PIP_EXTRA_INDEX_URL=http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/
La notation RUN |1 PIP_EXTRA_INDEX_URL=... signifie « un argument était défini pour cette instruction, avec cette valeur ». L'historique fait partie de la configuration de l'image : il voyage avec elle dans tous les registres.
Et ce n'est pas tout. Poussons l'image dans un registre local avec une attestation de provenance complète (la leçon 8 présente ces attestations ; la CI les active de plus en plus souvent par défaut) :
$ docker buildx build --network host --progress=quiet -t 127.0.0.1:10451/signalements:fuite -f Dockerfile.arg \
--build-arg PIP_EXTRA_INDEX_URL=$URL --provenance=mode=max --push .
$ docker buildx imagetools inspect 127.0.0.1:10451/signalements:fuite --format '{{json .Provenance}}' \
| jq '.SLSA.buildDefinition.externalParameters.request.args'
{
"build-arg:PIP_EXTRA_INDEX_URL": "http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/",
"cmdline": "docker/dockerfile:1",
"force-network-mode": "host",
"source": "docker/dockerfile:1"
}
L'attestation, conçue pour dire comment l'image a été construite, enregistre fidèlement tous les arguments, dont le mot de passe. Supprimer l'historique ne suffirait donc pas. (La provenance minimale, celle que BuildKit joint par défaut, n'enregistre pas les arguments, précisément pour éviter ce risque ; la leçon 8 compare les deux modes. Mais rien n'empêche une CI de passer en mode complet.)
Les vérifications de BuildKit (leçon 8 du cours précédent) détectent ce motif, mais seulement d'après le nom de l'argument. Comparons notre Dockerfile avec une copie où l'argument s'appelle DEPOT_TOKEN :
$ docker build --check -f Dockerfile.token .
...
WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ARG "DEPOT_TOKEN")
$ docker build --check -f Dockerfile.arg .
...
Check complete, no warnings found.
DEPOT_TOKEN déclenche l'alerte (le nom contient « TOKEN ») ; PIP_EXTRA_INDEX_URL, qui contient pourtant un mot de passe, passe inaperçu.
Fuite n° 2 : par ENV
Variante tout aussi répandue : ARG pour recevoir la valeur, puis ENV PIP_EXTRA_INDEX_URL=$PIP_EXTRA_INDEX_URL pour que pip la voie. La valeur se retrouve alors dans la configuration de l'image, visible par docker inspect, et dans l'environnement de chaque conteneur lancé à partir d'elle, où n'importe quelle faille de l'application permet de la lire (/proc/1/environ, une page d'erreur trop bavarde, un outil de supervision). C'est la pire des trois.
Fuite n° 3 : COPY puis suppression
Troisième réflexe : copier un fichier de configuration de pip, s'en servir, le supprimer.
# syntax=docker/dockerfile:1
FROM python:3.14-slim
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_ROOT_USER_ACTION=ignore
WORKDIR /app
COPY requirements.txt pip.conf ./
RUN PIP_CONFIG_FILE=/app/pip.conf pip install -r requirements.txt
RUN rm /app/pip.conf
COPY app.py .
USER 10001
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--no-control-socket", "app:app"]$ cat pip.conf
[global]
extra-index-url = http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/
$ docker build --network host -q -t signalements:copie -f Dockerfile.copie .
$ docker run --rm signalements:copie ls /app
app.py
requirements.txt
Le fichier a disparu du conteneur. Pas de l'image :
$ docker save signalements:copie -o copie.tar && mkdir extrait && tar -xf copie.tar -C extrait
$ for b in extrait/blobs/sha256/*; do
tar -tzf "$b" 2>/dev/null | grep -q 'app/pip.conf$' && { echo "trouvé dans la couche ${b##*/}"; tar -xzOf "$b" app/pip.conf; }
done
trouvé dans la couche 9190a46336c99e40012ce85d184e89cd34f020b214b868b76368ef788d89f6a6
[global]
extra-index-url = http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/
C'est le mécanisme de la leçon 7 du cours précédent : la couche du COPY contient le fichier, celle du rm ne fait que le masquer.
Les analyseurs de secrets aident, sans tout voir. Trivy examine chaque couche, pas seulement le système de fichiers final. Sur une image où un jeton GitHub a été copié puis supprimé, il le trouve et indique la couche fautive :
$ docker run --rm -v "$PWD":/t:ro -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 \
image --quiet --input /t/j.tar --scanners secret --format json \
| jq -c '[.Results[]? | select(.Secrets) | {Target, Secrets: [.Secrets[] | {RuleID, Severity, Layer: .Layer.CreatedBy}]}]'
[{"Target":"/root/.netrc-ci","Secrets":[{"RuleID":"github-pat","Severity":"CRITICAL","Layer":"COPY .netrc-ci /root/.netrc-ci # buildkit"}]}]
Mais sur notre image signalements:copie, il ne trouve rien :
$ docker run --rm -v "$PWD":/t:ro -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 \
image --quiet --input /t/copie.tar --scanners secret --format json \
| jq '[.Results[]? | select(.Secrets) | {Target, Secrets: [.Secrets[] | {RuleID, Title, Match}]}]'
[]
Un analyseur de secrets reconnaît des formats (jetons GitHub, clés AWS, clés privées...). Une URL avec un identifiant et un mot de passe quelconques n'a pas de format reconnaissable. L'absence d'alerte ne prouve donc pas l'absence de secret.
La bonne méthode : un montage de secret
# syntax=docker/dockerfile:1
FROM python:3.14-slim
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_ROOT_USER_ACTION=ignore
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=secret,id=pipconf,target=/etc/pip.conf \
pip install -r requirements.txt
COPY app.py .
USER 10001
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--no-control-socket", "app:app"]Le secret pipconf est monté, le temps du pip install, à l'emplacement /etc/pip.conf, le fichier de configuration global que pip lit sans autre option. Le fichier sur le poste (ou sur l'agent de CI) est fourni au lancement, et n'est jamais dans le contexte de construction :
$ cat ../pip-ci.conf
[global]
extra-index-url = http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/
$ docker buildx build --network host --progress=quiet -t 127.0.0.1:10451/signalements:secret \
--secret id=pipconf,src=../pip-ci.conf --provenance=mode=max --push .
Vérifions les traces :
$ docker run --rm 127.0.0.1:10451/signalements:secret sh -c 'python -c "import lyneko_outils; print(lyneko_outils.__name__)"; ls -l /etc/pip.conf'
lyneko_outils
ls: cannot access '/etc/pip.conf': No such file or directory
$ docker history 127.0.0.1:10451/signalements:secret --no-trunc --format '{{.CreatedBy}}' | grep "pip install -r"
RUN /bin/sh -c pip install -r requirements.txt # buildkit
$ docker buildx imagetools inspect 127.0.0.1:10451/signalements:secret --format '{{json .Provenance}}' \
| jq '.SLSA.buildDefinition.externalParameters.request.secrets'
[
{
"id": "pipconf",
"optional": true
}
]
$ docker buildx imagetools inspect 127.0.0.1:10451/signalements:secret --format '{{json .Provenance}}' | grep -c S3cret
0
La bibliothèque privée est installée ; /etc/pip.conf n'existe pas dans l'image ; l'historique montre l'instruction sans aucune valeur ; l'attestation de provenance enregistre seulement qu'un secret nommé pipconf a été utilisé, ce qui est exactement l'information utile pour un audit.
La variante en variable d'environnement
Beaucoup d'outils se configurent plus naturellement par une variable d'environnement. Depuis la version 1.10 de la syntaxe Dockerfile, le montage de secret sait aussi en créer une, limitée à l'instruction :
RUN --mount=type=secret,id=index,env=PIP_EXTRA_INDEX_URL \
pip install -r requirements.txtEt la valeur peut venir d'une variable d'environnement du poste ou de l'agent de CI, sans fichier intermédiaire :
$ export URL_DEPOT='http://ci-lyneko:S3cret-Depot-2026@127.0.0.1:10450/simple/'
$ docker build --network host --progress=plain --no-cache --secret id=index,env=URL_DEPOT -f Dockerfile.env -t signalements:env .
...
#10 0.888 Looking in indexes: https://pypi.org/simple, http://ci-lyneko:****@127.0.0.1:10450/simple/
#10 1.299 Collecting lyneko-outils==1.0.0 (from -r requirements.txt (line 4))
...
$ docker run --rm signalements:env sh -c 'env | grep -c PIP_EXTRA'
0
Remarquez que pip masque lui-même le mot de passe dans son journal (****). Tous les outils ne le font pas : une commande qui affiche sa configuration, un mode verbeux, un message d'erreur peuvent écrire le secret dans les journaux de la CI. Les secrets déclarés dans GitHub Actions sont masqués automatiquement dans les journaux ; une valeur reconstruite ou encodée (en base64, par exemple) ne l'est pas.
Un agent SSH plutôt qu'une clé
Pour cloner un dépôt Git privé, on serait tenté de copier une clé de déploiement dans l'image. BuildKit sait faire mieux : transmettre l'agent SSH de la machine qui lance la construction.
# syntax=docker/dockerfile:1
FROM alpine:3.22
RUN apk add --no-cache openssh-client git
RUN --mount=type=ssh \
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" \
&& ssh-add -l \
&& ls /root/.ssh 2>&1 || true$ eval "$(ssh-agent -s)"
$ ssh-add cle-deploiement
$ ssh-keygen -lf cle-deploiement.pub
256 SHA256:MR7ci3aOC5HoaS//M08JRmfsWqgbzaLoJtP5wD11g2w deploiement-ci@lyneko (ED25519)
$ docker build --progress=plain --no-cache --ssh default -t essai-ssh .
...
#8 [stage-0 3/3] RUN --mount=type=ssh echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" && ssh-add -l && ls /root/.ssh 2>&1 || true
#8 0.211 SSH_AUTH_SOCK=/run/buildkit/ssh_agent.0
#8 0.219 256 SHA256:MR7ci3aOC5HoaS//M08JRmfsWqgbzaLoJtP5wD11g2w deploiement-ci@lyneko (ED25519)
#8 0.221 ls: /root/.ssh: No such file or directory
#8 DONE 0.2s
Pendant l'instruction, SSH_AUTH_SOCK pointe vers un socket fourni par BuildKit, qui relaie les demandes de signature vers l'agent de votre machine. La clé est utilisable (même empreinte) mais absente : il n'y a même pas de répertoire /root/.ssh. ssh et git utilisent l'agent sans aucune configuration. Sans --ssh au lancement, l'agent n'est pas joignable :
$ docker build --progress=plain --no-cache -t essai-ssh .
...
#8 0.228 SSH_AUTH_SOCK=/run/buildkit/ssh_agent.0
#8 0.235 Error connecting to agent: No such file or directory
Pour un vrai git clone, il faut aussi que ssh reconnaisse le serveur : on ajoute sa clé publique dans /root/.ssh/known_hosts (une information publique, qu'on peut copier dans l'image), plutôt que de désactiver la vérification avec StrictHostKeyChecking=no, qui ouvre la porte à une interception.
Sous le capot
Où vit le secret pendant l'instruction. BuildKit reçoit le secret du client par la session gRPC qui relie docker buildx build au builder, au moment où l'instruction en a besoin. Il le monte dans un système de fichiers en mémoire (tmpfs), à l'intérieur du conteneur temporaire de l'instruction RUN, puis le démonte avant de prendre l'instantané de la couche. Le fichier n'a donc jamais existé dans le système de fichiers capturé : il n'y a rien à masquer, rien à supprimer.
Pourquoi le secret n'entre pas dans la clé de cache. La clé de cache (leçon 3) dépend du texte de l'instruction et de ses montages, mais pas du contenu des secrets. C'est voulu : sinon, chaque rotation de mot de passe invaliderait toutes les constructions, et le cache exporté permettrait de deviner un secret en testant des valeurs. La conséquence est développée dans les pièges.
optional: true dans la provenance. Par défaut, un montage de secret est facultatif : si le secret n'est pas fourni, l'instruction s'exécute sans lui. L'option required=true (ou required seul) fait échouer la construction immédiatement, avec un message clair, au lieu de laisser l'outil échouer plus loin avec un message trompeur.
Pièges courants
Le secret oublié, et le message qui égare. Construisons sans fournir le secret :
$ docker build --network host --progress=plain --no-cache -t essai .
...
#10 1.162 ERROR: Could not find a version that satisfies the requirement lyneko-outils==1.0.0 (from versions: none)
#10 1.162 ERROR: No matching distribution found for lyneko-outils==1.0.0
Rien ne dit qu'un secret manque : le montage facultatif est simplement absent, pip ne connaît pas le dépôt privé et cherche lyneko-outils sur PyPI. Écrivez --mount=type=secret,id=pipconf,target=/etc/pip.conf,required pour obtenir une erreur explicite.
Un mot de passe faux donne le même message. Avec un mot de passe erroné, pip ne signale pas l'erreur d'authentification (401), il conclut que le paquet n'existe pas :
$ docker build --network host --progress=plain --no-cache --secret id=pipconf,src=../pip-faux.conf -t essai .
...
#10 1.000 Looking in indexes: https://pypi.org/simple, http://ci-lyneko:****@127.0.0.1:10450/simple/
...
#10 1.187 ERROR: No matching distribution found for lyneko-outils==1.0.0
Face à un « No matching distribution » sur un paquet privé, vérifiez d'abord le secret : qu'il est fourni, qu'il est valide, qu'il n'a pas expiré.
Le cache masque un secret expiré. Construisons une fois avec le bon mot de passe, puis avec un mot de passe faux, sans --no-cache :
$ docker build --network host --progress=plain --secret id=pipconf,src=../pip-faux.conf -t essai .
...
#8 [stage-0 2/5] WORKDIR /app
#8 CACHED
#9 [stage-0 3/5] COPY requirements.txt .
#9 CACHED
#10 [stage-0 4/5] RUN --mount=type=secret,id=pipconf,target=/etc/pip.conf pip install -r requirements.txt
#10 CACHED
La construction réussit : le pip install est repris du cache, le secret n'a pas été utilisé. Le jour où le cache est vidé (nouvel agent, requirements.txt modifié), elle échoue. Un jeton expiré peut ainsi passer des semaines inaperçu. Une construction périodique sans cache (leçon 3) le détecte.
Le secret dans un montage de cache. Un montage type=cache persiste d'une construction à l'autre et peut être partagé : un outil qui y écrit sa configuration (certains gestionnaires de paquets mettent les identifiants dans leur répertoire de cache) y laisse le secret. Gardez les deux types de montage séparés, et vérifiez où l'outil range ses identifiants.
Le secret dans une étape intermédiaire. Un COPY d'un fichier secret dans l'étape construction d'un Dockerfile multi-étapes ne le met pas dans l'image finale, mais il reste dans le cache exporté en mode=max, et dans l'image de l'étape si quelqu'un la construit avec --target. Les montages de secret s'utilisent aussi dans les étapes intermédiaires.
Sécurité
- Considérez comme compromis tout secret qui a touché une couche, un historique ou une attestation, même supprimé ensuite, même dans une image jamais poussée (elle a pu être mise en cache, copiée, sauvegardée). La seule remédiation fiable est la rotation : révoquer le secret et en émettre un nouveau. Réécrire l'image ne suffit pas.
- Des secrets à durée de vie courte. En CI, préférez des jetons émis pour la tâche et valides quelques minutes (fédération d'identité OIDC entre GitHub Actions et le registre ou le dépôt privé, leçon 10) aux mots de passe permanents stockés dans les paramètres du projet.
- Des droits minimaux. Le compte utilisé pour lire le dépôt privé n'a que le droit de lecture : s'il fuit, l'attaquant peut lire vos bibliothèques, pas les remplacer. Un compte qui peut publier des paquets est une cible de choix pour une attaque de la chaîne d'approvisionnement.
- Contrôlez à chaque construction, avec plusieurs filets : la vérification
SecretsUsedInArgOrEnvde BuildKit, l'analyse de secrets de Trivy sur toutes les couches, et une recherche explicite des valeurs sensibles connues dans l'historique et la provenance. Aucun n'est complet seul, comme nous l'avons vu.
En production
- En GitHub Actions, l'action
docker/build-push-actionacceptesecrets:(pairesid=valeur) etssh:: elle les transmet comme--secretet--ssh. Les valeurs viennent des secrets du dépôt ou, mieux, d'un jeton obtenu par OIDC. La leçon 10 l'intègre au workflow complet. - Un miroir interne des dépôts de paquets (proxy PyPI, npm, Maven, comme Nexus, Artifactory ou devpi) évite d'avoir besoin d'identifiants différents par source, et donne un point unique pour filtrer et auditer les paquets entrants.
- Les clés SSH de déploiement sont propres à un dépôt et en lecture seule ; avec
--ssh, elles restent dans l'agent de la CI. Pour GitHub, un jeton d'installation d'application GitHub, à durée de vie courte, est souvent préférable à une clé SSH permanente. - Écrivez la règle dans les conventions d'équipe et faites-la vérifier en revue de code : aucun
ARG,ENVouCOPYde valeur sensible dans un Dockerfile, montages de secret obligatoires.
Exercices
1. Trouver les fuites. Pour chacun de ces fragments, dites où le secret se retrouve (couche, configuration, historique, provenance) :
(a) ARG NPM_TOKEN puis RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && npm ci && rm .npmrc ;
(b) ENV API_KEY=... dans une étape intermédiaire d'un Dockerfile multi-étapes ;
(c) RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci.
Solution
(a) Historique (la ligne ARG NPM_TOKEN=... et le RUN qui montre |1 NPM_TOKEN=...) et provenance si elle est en mode=max (build-arg:NPM_TOKEN) ; le .npmrc créé et supprimé dans la même instruction n'apparaît pas dans la couche, mais cela ne rattrape pas les deux autres fuites. (b) Toute image construite avec --target vers cette étape (dans sa configuration), et, ce qui surprend, l'attestation de provenance complète de l'image finale : en mode=max, elle enregistre la définition de toutes les étapes, environnement compris (vérifié avec un ARG déclaré seulement dans une étape intermédiaire, retrouvé trois fois dans la provenance). Pas les couches de l'image finale. (c) Nulle part : seul l'identifiant npmrc figure dans la provenance.
2. Rendre l'erreur explicite. Modifiez le Dockerfile de la leçon pour qu'une construction sans secret échoue immédiatement avec un message qui le dit. Vérifiez.
Solution
Ajoutez required au montage :
RUN --mount=type=secret,id=pipconf,target=/etc/pip.conf,required \
pip install -r requirements.txtSans --secret id=pipconf,..., BuildKit arrête la construction à cette instruction, avant même de lancer pip :
#10 [stage-0 4/5] RUN --mount=type=secret,id=pipconf,target=/etc/pip.conf,required pip install -r requirements.txt
#10 ERROR: secret pipconf: not foundau lieu de laisser pip échouer avec « No matching distribution found ».
3. Auditer une image (niveau 300). On vous confie une image construite par un prestataire, poussée dans votre registre. Écrivez la procédure, avec les commandes, pour vérifier qu'elle ne contient pas de secret de construction.
Solution
- Historique :
docker history --no-trunc <image>, à la recherche de|N VAR=dans lesRUNet de valeurs dans lesARG/ENV. 2. Configuration :docker image inspect --format '{{json .Config.Env}}'. 3. Provenance :docker buildx imagetools inspect <image> --format '{{json .Provenance}}', en particulierexternalParameters.request.args. 4. Couches :docker savepuis analyse de toutes les couches, avec Trivy (--scanners secret) et avec une recherche ciblée (tar -tzfsur chaque couche, à la recherche de.netrc,.npmrc,pip.conf,id_*,.env,*.pem). 5. Conclusion écrite : ce qui a été vérifié, avec quels outils, et la limite (un secret sans format reconnaissable peut échapper aux analyseurs). Tout secret trouvé est considéré compromis et changé.
4. Cloner un dépôt privé (niveau 200). Écrivez un Dockerfile qui clone un dépôt Git privé par SSH pendant la construction, avec vérification de la clé du serveur, sans jamais copier de clé privée.
Solution
# syntax=docker/dockerfile:1
FROM alpine:3.22 AS sources
RUN apk add --no-cache openssh-client git
COPY known_hosts /root/.ssh/known_hosts
RUN --mount=type=ssh,required \
git clone --depth 1 git@github.com:lyneko-team/bibliotheque-privee.git /srcLe fichier known_hosts contient la clé publique du serveur Git, obtenue une fois par un canal de confiance (pour GitHub, les empreintes publiées dans sa documentation) et versionnée avec le projet. La construction se lance avec --ssh default sur une machine dont l'agent contient une clé de déploiement en lecture seule. L'étape sources sert ensuite de source à un COPY --from=sources dans l'étape qui en a besoin. (Ce Dockerfile a été validé avec docker build --check ; le clonage lui-même suppose un dépôt privé et une clé de déploiement, que vous adapterez à votre contexte. Le fonctionnement de l'agent pendant la construction a été démontré dans la leçon.)
Récapitulatif
- Un secret passé par
ARGfuit dans l'historique et dans l'attestation de provenance ; parENV, dans la configuration et l'environnement de chaque conteneur ; parCOPYpuis suppression, dans la couche. - Les vérifications de BuildKit ne reconnaissent que les noms suspects, les analyseurs de secrets que les formats connus : aucun outil ne voit tout.
RUN --mount=type=secret(fichier avectarget=, ou variable avecenv=) et--secretau lancement : le secret n'existe que le temps de l'instruction, dans un tmpfs, et seul son identifiant est enregistré.RUN --mount=type=sshet--ssh defaultdonnent accès à l'agent SSH, jamais à la clé.- Le secret ne fait pas partie de la clé de cache : un secret expiré peut rester invisible tant que le cache sert. Ajoutez
requiredaux montages, reconstruisez sans cache régulièrement. - Tout secret ayant touché une image est compromis : on le change.
Pour aller plus loin
- La page Build secrets de la documentation Docker, qui détaille les sources possibles des secrets et les montages SSH.
- L'OWASP Secrets Management Cheat Sheet, pour le cycle de vie des secrets au-delà de la construction (émission, rotation, révocation).
- Leçon suivante : les images minimales, distroless et
scratch, où l'absence d'outils devient elle-même une mesure de sécurité.
Sources
- Docker, Build secrets
- Docker, Dockerfile reference : RUN --mount=type=secret et type=ssh
- Docker, Build check SecretsUsedInArgOrEnv
- Docker, Provenance attestations (paramètres de construction enregistrés)
- pip, Configuration (fichiers de configuration et variables d'environnement)
- pypiserver, serveur PyPI minimal
- Trivy, Secret scanning
- OWASP, Secrets Management Cheat Sheet