Aller au contenu
Les secrets pendant la construction

Les secrets pendant la construction

200 Pratiquer ⏱ 55 min dockerbuildkitpython

À la fin, vous saurez

  • Montrer où fuit un secret passé par ARG, ENV ou COPY, y compris dans l'attestation de provenance
  • Utiliser RUN --mount=type=secret avec un fichier ou une variable d'environnement
  • Transmettre un agent SSH à la construction sans copier de clé
  • Vérifier qu'une image et ses métadonnées ne contiennent aucun secret
  • Expliquer pourquoi un secret ne fait pas partie de la clé de cache, et ce que cela implique

Prérequis

Testé avec buildkit 0.33.0 docker 29.8.1 pypiserver 2.4.2 python 3.14.7 trivy 0.74.0 , vérifié le 1 octobre 2026

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

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

TraceCe qu'elle contientQui y a accès
Les couchesTous les fichiers ajoutés à chaque étape, y compris ceux supprimés plus tardQuiconque peut télécharger l'image
La configuration de l'imageLes variables ENV, l'utilisateur, la commandeIdem, et docker inspect
L'historiqueLe texte de chaque instruction, avec la valeur des ARG déclarés avant chaque RUNIdem, et docker history
L'attestation de provenanceLes paramètres de la construction, dont les arguments (--build-arg)Quiconque peut lire l'image dans le registre
Le cache de constructionLes couches des étapes intermédiairesQuiconque accède au builder ou au cache exporté
Les journaux de la CITout ce que les commandes affichentQuiconque 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 (ou env=VARIABLE) au lancement, et RUN --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 avec target=), ou exposé comme variable d'environnement avec env=, pendant cette seule instruction ;
  • --ssh default et RUN --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.txt

Et 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 SecretsUsedInArgOrEnv de 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-action accepte secrets: (paires id=valeur) et ssh: : elle les transmet comme --secret et --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, ENV ou COPY de 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.txt

Sans --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 found

au 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
  1. Historique : docker history --no-trunc <image>, à la recherche de |N VAR= dans les RUN et de valeurs dans les ARG/ENV. 2. Configuration : docker image inspect --format '{{json .Config.Env}}'. 3. Provenance : docker buildx imagetools inspect <image> --format '{{json .Provenance}}', en particulier externalParameters.request.args. 4. Couches : docker save puis analyse de toutes les couches, avec Trivy (--scanners secret) et avec une recherche ciblée (tar -tzf sur 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 /src

Le 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 ARG fuit dans l'historique et dans l'attestation de provenance ; par ENV, dans la configuration et l'environnement de chaque conteneur ; par COPY puis 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 avec target=, ou variable avec env=) et --secret au lancement : le secret n'existe que le temps de l'instruction, dans un tmpfs, et seul son identifiant est enregistré.
  • RUN --mount=type=ssh et --ssh default donnent 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 required aux 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é.
Voir ma constellation →

Sources