Aller au contenu
Sécurité medium

Fuites de secrets : logs, Docker, variables

27 min de lecture

"Si ce n'est pas dans Git, c'est bon." C'est une idée fausse. Les secrets fuient aussi dans les logs applicatifs, les images Docker, les variables d'environnement, les dumps mémoire et bien d'autres endroits. Ce guide identifie ces vecteurs de fuite au-delà du code source, les classe par famille et explique les contre-mesures réellement fiables selon le contexte.

  • Les trois familles de fuite : observabilité, build/artefacts, exécution
  • Comment les secrets fuient dans les logs, traces et stack traces
  • La distinction entre build secrets et runtime secrets dans Docker
  • Pourquoi les variables d'environnement sont plus exposées qu'on ne le pense
  • Les contre-mesures pour les dumps, les métadonnées cloud et les fichiers d'auth locaux

Les secrets fuient dans bien plus d'endroits que le code source. Pour mieux appréhender ces surfaces, on peut les regrouper en trois familles :

Tout ce qui sert à comprendre le comportement de l'application peut capturer un secret au passage : logs, traces distribuées, stack traces, tickets de support, dumps partagés.

Tout ce qui est produit lors de la construction du logiciel peut embarquer un secret : layers Docker, caches de build, artefacts de CI, fichiers de configuration générés.

Tout ce qui est accessible au runtime peut exposer un secret : variables d'environnement, /proc, métadonnées cloud (IMDS), mémoire du processus.

Ce tableau sert de carte des risques : la colonne « Qui y a accès » est la plus instructive, car elle montre que la fuite ne s'arrête presque jamais à l'endroit où elle se produit. Un secret écrit dans un log part vers l'agrégateur, puis vers l'archivage, puis vers les sauvegardes. Utilisez-le comme grille de revue, surface par surface, plutôt que comme simple récapitulatif.

FamilleSurfaceComment le secret fuitQui y a accès
ObservabilitéLogs applicatifsAffiché en debug, traces HTTPOps, SIEM, archives
ObservabilitéTraces et stack tracesSérialisation d'objets, middlewareOps, APM, archives
ObservabilitéTickets et chatCopié-collé pour debugÉquipe, archives
ObservabilitéDumps partagésAttachés à un ticket ou envoyés à un éditeurSupport, archives
BuildImages Dockerdocker history, layers, cacheUtilisateurs du registry
BuildArtefacts CILogs de build, fichiers temporairesÉquipes CI/CD
ExécutionVariables d'environnement/proc/*/environ, dumps, enfantsProcessus liés, root
ExécutionDumps mémoireCore dumps, heap dumpsAdmins, debug
ExécutionMétadonnées cloudInstance metadata (IMDS)Tout processus sur l'instance
ExécutionFichiers d'auth locaux.npmrc, kubeconfig, state TerraformUtilisateurs, CI

C'est la famille la plus sous-estimée, parce que les outils concernés sont ceux censés vous aider. Un log est conçu pour être conservé, indexé et partagé largement : un secret qui y entre acquiert instantanément ces trois propriétés. Pire, la rotation d'un secret ne nettoie pas les archives, l'ancienne valeur reste consultable aussi longtemps que la rétention le permet.

Les secrets fuient dans les logs et traces de multiples manières, pas seulement via un logger.info explicite :

# ❌ Le secret apparaît dans les logs
logger.info(f"Connecting to database with password: {db_password}")
# ❌ L'URL contient le mot de passe
logger.debug(f"Database URL: {connection_string}")
# Affiche: postgresql://user:SuperSecret@db:5432/mydb

Mais aussi dans des cas moins évidents :

  • Traces HTTP : headers Authorization, query strings avec tokens
  • Middleware de debug : sérialisation brute de la requête entrante
  • Stack traces : variables locales capturées dans l'exception
  • Sérialisation automatique : objets de configuration loggés en entier
  • Logs de reverse proxies : Nginx/HAProxy qui loggent les headers par défaut

Une ligne de log ne reste pas sur le serveur qui l'a produite. Elle est collectée, transportée, indexée, puis archivée, souvent sur plusieurs systèmes dont les droits d'accès n'ont rien à voir avec ceux de l'application d'origine. Le schéma ci-dessous suit ce trajet.

Application → stdout → Container runtime → Node logs → Agrégateur (Loki, ELK)
Archivage S3
Backups

Le secret traverse toute la chaîne et persiste dans les archives.

La défense se construit sur trois niveaux, du plus fiable au plus tardif. Ne rien écrire de sensible reste la seule mesure sûre ; le masquage applicatif rattrape les oublis avant l'émission ; le filtrage dans l'agrégateur intervient en dernier recours, une fois la ligne déjà sortie du processus. Empilez les trois, aucun ne suffit seul.

1. Ne jamais logger de secrets :

# ✅ Logger sans le secret
logger.info("Connecting to database")
logger.info(f"Database host: {db_host}, port: {db_port}")

2. Utiliser des masques dans les frameworks de logging :

# Python avec structlog
import structlog
def mask_secrets(logger, method_name, event_dict):
for key in ['password', 'secret', 'token', 'api_key']:
if key in event_dict:
event_dict[key] = '***REDACTED***'
return event_dict
structlog.configure(processors=[mask_secrets, ...])

3. Configuration du log aggregator :

# Loki / Promtail : masquer les patterns
scrape_configs:
- pipeline_stages:
- replace:
expression: '(password|secret|token)=([^&\s]+)'
replace: '${1}=***REDACTED***'

À dérouler lors d'une revue de code ou d'une recette avant mise en production. Les deux derniers points sont les plus souvent oubliés, car ils ne dépendent pas du code applicatif mais de la configuration d'infrastructure.

  • Aucun logger.info/debug avec des secrets
  • URLs de connexion sans credentials dans les logs
  • Masques configurés dans le framework de logging
  • Masques configurés dans l'agrégateur de logs
  • Pas de sérialisation brute d'objets de configuration ou de requêtes HTTP
  • Reverse proxies configurés pour ne pas logger les headers sensibles

Une image de conteneur est immuable et distribuable : c'est tout son intérêt, et c'est aussi ce qui rend une fuite irrattrapable. Une fois l'image poussée sur un registre, toute personne autorisée à la télécharger peut en extraire les couches, y compris celles d'une étape intermédiaire. Corriger le Dockerfile ne suffit donc pas, il faut considérer le secret comme compromis et le renouveler.

Le risque ne se limite pas à docker history. Plusieurs mécanismes peuvent capturer un secret lors du build :

  • Commande visible dans l'historique : un ARG ou ENV avec un secret apparaît dans les métadonnées de l'image
  • Fichier copié puis supprimé : le fichier reste dans un layer intermédiaire même après RUN rm
  • Secret récupéré par un outil, puis persisté : un npm ci qui écrit un .npmrc dans un fichier de config final
  • Cache de build : les caches de layers peuvent conserver des traces

Chaque instruction dans un Dockerfile crée un layer. Même si vous supprimez un fichier, il existe toujours dans les layers précédents.

# ❌ Le secret est dans un layer intermédiaire
FROM alpine
COPY .env /app/.env
RUN cat /app/.env && ./setup.sh
RUN rm /app/.env # Le secret est TOUJOURS dans le layer précédent

Ces deux commandes n'exigent aucun privilège particulier, seulement l'accès à l'image. La première expose les instructions du Dockerfile telles qu'enregistrées dans les métadonnées, la seconde décompresse toutes les couches sur le disque, y compris celles contenant des fichiers ultérieurement « supprimés ».

Fenêtre de terminal
docker history my-image --no-trunc
# Affiche toutes les commandes, y compris les secrets en ARG/ENV
docker save my-image | tar -xf -
# Extrait tous les layers, y compris ceux "supprimés"

Docker fournit un mécanisme dédié pour exposer un secret uniquement pendant une instruction RUN, sans qu'il soit enregistré dans un layer.

# ✅ Le secret n'est jamais dans un layer
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN --mount=type=secret,id=db_password \
cat /run/secrets/db_password | ./setup.sh
Fenêtre de terminal
docker build --secret id=db_password,src=./db_password.txt .

Le multi-stage build découpe la construction en plusieurs étapes dont une seule produit l'image finale. Les couches des étapes intermédiaires ne sont pas publiées : seuls les fichiers explicitement recopiés avec COPY --from= arrivent dans le résultat. Combiné au montage de secret, c'est la configuration de référence pour installer des dépendances privées.

# ✅ Le secret n'est que dans le stage de build
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
FROM node:20-slim@sha256:2cf067cfed83d5ea958367df9f966191a942351a2df77d6f0193e162b5febfc0
COPY --from=builder /app/dist /app/dist
# Le .npmrc n'est PAS dans l'image finale

ARG sert à paramétrer un build, pas à y injecter une valeur confidentielle : sa valeur est conservée dans les métadonnées de l'image et ressort telle quelle avec docker history. Le contournement fréquent qui consiste à écrire la valeur dans un fichier temporaire n'arrange rien, puisque ce fichier crée à son tour une couche.

# ❌ ARG est visible dans docker history
ARG DB_PASSWORD
RUN echo $DB_PASSWORD > /tmp/setup && ./setup.sh
# ✅ Utiliser --mount=type=secret
RUN --mount=type=secret,id=db_password \
cat /run/secrets/db_password | ./setup.sh

La confusion entre ces deux besoins conduit à des montages inutiles ou dangereux. Un build secret n'existe que le temps d'une instruction RUN et disparaît avec elle ; un runtime secret doit être disponible à chaque démarrage du conteneur, pendant toute sa durée de vie. Aucun mécanisme ne couvre correctement les deux.

La checklist "scanner avec Trivy ou Grype" est bien plus utile avec un exemple concret. Trivy dispose d'un scanner de secrets intégré qui analyse les layers de l'image :

Fenêtre de terminal
# Scanner une image pour détecter des secrets dans les layers
trivy image --scanners secret my-image:latest
# Scanner un répertoire local (avant le build)
trivy fs --scanners secret .

Ces cinq points se vérifient à la relecture d'un Dockerfile, sauf le dernier qui s'automatise en intégration continue. Le scan reste indispensable même si les quatre premiers sont respectés : il attrape les secrets arrivés par une dépendance ou un outil tiers.

  • Multi-stage builds systématiques
  • --mount=type=secret pour les secrets de build
  • Jamais de COPY .env ou ARG SECRET
  • Build cache nettoyé régulièrement
  • Scan des images avec trivy image --scanners secret

La variable d'environnement s'est imposée comme le format d'injection par défaut, notamment sous l'influence des douze facteurs. Elle règle un problème de portabilité, pas un problème de sécurité : une fois posée, elle est lisible par tous les processus enfants et par les outils d'introspection du système. La traiter comme une protection est l'erreur de raisonnement la plus répandue sur ce sujet.

Le problème : visibilité élargie et persistance accidentelle

Section intitulée « Le problème : visibilité élargie et persistance accidentelle »

Les variables d'environnement circulent facilement entre processus liés, peuvent réapparaître dans /proc, les outils de debug, les dumps ou les handlers d'erreur, et sont donc souvent plus exposées qu'on ne le pense.

Concrètement :

  • Les processus enfants héritent de l'environnement au moment du fork/exec
  • /proc/<pid>/environ expose l'environnement initial du processus (les permissions, le namespace et les restrictions ptrace/procfs conditionnent l'accès réel)
  • Les outils de debug et handlers d'erreur peuvent sérialiser l'environnement complet
  • Les dumps de crash capturent la mémoire, y compris les env vars
Fenêtre de terminal
# Un processus avec les permissions suffisantes peut lire l'env initial
cat /proc/1/environ | tr '\0' '\n'
# DB_PASSWORD=SuperSecret

Ces deux situations n'ont rien de théorique : la première vient d'un script de diagnostic laissé en place, la seconde d'un gestionnaire d'erreurs configuré pour joindre « tout le contexte » au rapport. Dans les deux cas, la valeur part sur la sortie standard et rejoint la chaîne de collecte des logs.

Fenêtre de terminal
# ❌ printenv dans un script de debug
printenv
# Affiche tous les secrets
# ❌ Error dump qui inclut l'environnement
Exception: Connection failed
Environment: {'DB_PASSWORD': 'SuperSecret', ...}

Les trois mesures qui suivent ne sont pas équivalentes. Passer par un fichier change réellement le modèle d'exposition : le contenu n'est plus hérité par les processus enfants et les permissions du système de fichiers s'appliquent. L'injection runtime va plus loin en confiant la distribution à un agent. La troisième, réduire la durée de vie en mémoire, relève de l'hygiène et ne remplace ni l'une ni l'autre.

1. Préférer les fichiers secrets :

# Kubernetes : volume plutôt que env
volumes:
- name: db-creds
secret:
secretName: db-credentials
defaultMode: 0400
containers:
- name: app
volumeMounts:
- name: db-creds
mountPath: /etc/secrets
readOnly: true
# Docker Compose : secrets montés en fichiers
services:
app:
image: my-app:latest
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
# Le secret est monté dans /run/secrets/db_password
# Application : lire le fichier
with open('/etc/secrets/db-password') as f:
db_password = f.read().strip()

2. Injection runtime avec Vault Agent :

template {
source = "/etc/vault/templates/db.tpl"
destination = "/etc/secrets/db.env"
perms = 0400
}

3. Réduire le temps d'exposition :

import os
db_password = os.environ.pop('DB_PASSWORD', None)
connect(password=db_password)
db_password = None

Les deux premiers points relèvent du déploiement, les trois suivants du code. Cette séparation compte au moment de l'audit : corriger le manifeste de déploiement ne protège pas d'un gestionnaire d'erreurs bavard resté dans l'application.

  • Secrets critiques en fichiers plutôt qu'en env vars
  • Permissions restrictives sur les fichiers secrets (0400)
  • Pas de printenv ou env dans les scripts
  • Error handlers qui n'affichent pas l'environnement
  • Pas de sérialisation brute des env vars dans les logs d'erreur

Un secret finit toujours par exister en clair dans la mémoire du processus, quel que soit son mode de distribution. Tout mécanisme capable d'écrire cette mémoire sur disque devient donc un vecteur de fuite : core dump après un crash, heap dump déclenché pour analyser une consommation, profil de performance exposé sur un port HTTP. Ces artefacts sont d'autant plus dangereux qu'ils circulent, on les joint à un ticket ou on les envoie à un éditeur.

Un crash peut générer un core dump contenant toute la mémoire du processus, y compris les secrets.

Désactiver la génération de core dumps :

Fenêtre de terminal
# Au niveau du processus/shell
ulimit -c 0
# Au niveau système (systemd-coredump)
# /etc/systemd/coredump.conf
[Coredump]
Storage=none
ProcessSizeMax=0

Restreindre les capacités du conteneur :

# Kubernetes : réduire la surface d'attaque
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]

Contrairement au core dump, le heap dump est produit volontairement, souvent en pleine analyse d'incident et sous pression. Il contient l'intégralité des objets vivants du processus, donc les chaînes de connexion, les jetons et les mots de passe encore référencés.

Fenêtre de terminal
# ❌ Le heap dump contient tous les secrets en mémoire
jmap -dump:format=b,file=heap.hprof <pid>
node --heapsnapshot-signal=SIGUSR2 app.js

Le vrai danger n'est pas seulement la génération du dump, mais ce qui en est fait ensuite. Un dump devient souvent un artefact partagé : copié hors de la machine, attaché à un ticket, transmis à un éditeur, stocké dans un bucket ou un espace partagé, et oublié là.

Solutions :

  • Désactiver les endpoints de debug en production
  • Restreindre l'accès aux outils de dump
  • Ne pas persister les heap dumps dans des endroits accessibles
  • Traiter les dumps comme des données sensibles (chiffrement, rétention limitée)

En Go, l'import anonyme de net/http/pprof enregistre automatiquement ses routes de diagnostic sur le serveur HTTP par défaut. Si ce serveur est celui qui expose votre API, les profils deviennent accessibles à quiconque atteint le service, et le point d'entrée /debug/pprof/heap livre un instantané de la mémoire. La parade consiste à servir ces routes sur un écouteur séparé, lié à localhost.

// ❌ Endpoint pprof exposé sans authentification
import _ "net/http/pprof"
// ✅ Endpoint pprof sur un port interne séparé
go func() {
http.ListenAndServe("localhost:6060", nil)
}()

L'IMDS (Instance Metadata Service) est un service HTTP local, joignable à l'adresse 169.254.169.254, qui distribue aux applications les credentials du rôle attaché à l'instance. Sa particularité : il ne demande aucune authentification, la simple capacité à émettre une requête depuis la machine suffit. C'est ce qui en fait la cible privilégiée des failles SSRF, où l'on force une application à effectuer une requête pour le compte de l'attaquant.

Sur AWS, GCP, Azure, tout processus sur une instance peut potentiellement récupérer des credentials via le service de métadonnées (IMDS) :

Fenêtre de terminal
# AWS IMDSv1 : récupérer les credentials du rôle IAM (une seule requête)
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role

La protection contre les fuites via IMDS repose sur plusieurs couches, pas sur un seul mécanisme.

1. IMDSv2 obligatoire (priorité absolue) :

IMDSv2 exige un token obtenu par une requête PUT préalable, ce qui rend l'exploitation par SSRF beaucoup plus difficile.

Fenêtre de terminal
# Configurer l'instance pour n'accepter que IMDSv2
aws ec2 modify-instance-metadata-options \
--instance-id i-xxx \
--http-tokens required

2. Restrictions réseau en complément :

# Kubernetes NetworkPolicy (complément, pas solution unique)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: block-metadata
spec:
podSelector: {}
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.169.254/32

3. Cloisonnement des workloads :

Restreindre quels pods ou processus ont réellement besoin d'accéder aux métadonnées.

4. Préférer les identités de workload :

Une surface souvent oubliée : les fichiers d'authentification présents sur les postes de développement et les runners CI. Ces fichiers contiennent des tokens, des credentials ou des états sensibles :

FichierContenu sensible
.npmrcToken de registry npm privé
.pypircCredentials PyPI
~/.docker/config.jsonTokens de registries Docker
~/.kube/configCertificats et tokens Kubernetes
terraform.tfstateState Terraform (peut contenir des passwords, clés)
~/.aws/credentialsClés d'accès AWS
~/.config/gcloud/Credentials Google Cloud

Ce guide montre que les secrets fuient bien au-delà du code source. Mais attention à ne pas en tirer de fausses conclusions :

  • Ne pas mettre un secret dans Git ne suffit pas, il peut fuir par les logs, les images ou l'environnement
  • Mettre un secret en variable d'environnement ne le sécurise pas, c'est un mode de distribution, pas une mesure de protection
  • Mettre un secret dans un Secret Kubernetes n'en fait pas un coffre-fort, c'est un objet encodé en base64, pas chiffré par défaut
  • Supprimer un fichier d'une image finale n'efface pas sa trace du build, les layers intermédiaires persistent
  • Désactiver un endpoint de debug ne protège pas le dump déjà généré, les artefacts peuvent avoir été copiés ailleurs

Voici les neuf surfaces du guide avec leur contre-mesure principale, à utiliser comme support de revue plutôt que comme lecture linéaire. Une remarque en le parcourant : aucune ligne ne se règle par un outil unique, chacune combine une décision de conception et un réglage d'exploitation.

FamilleSurfaceRisqueContre-mesure
ObservabilitéLogsArchivage long termeMasques, ne pas logger de secrets
ObservabilitéTraces et stack tracesSérialisation automatiqueFiltrage des headers, masquage
BuildDocker layersdocker history, extractionMulti-stage, --mount=type=secret
BuildCache et artefactsPersistence du secretNettoyage de cache, scan Trivy
ExécutionVariables env/proc, héritage, dumpsFichiers, injection runtime
ExécutionCore dumpsMémoire complèteulimit -c 0, Storage=none
ExécutionHeap dumpsArtefact partagéDésactiver en prod, rétention
ExécutionIMDSCredentials du nœudIMDSv2, workload identity
ExécutionFichiers d'authTokens permanentsPermissions, auth éphémère
  1. Les fuites hors Git se répartissent en trois familles, observabilité, build/artefacts, exécution, chacune avec ses propres contre-mesures
  2. Distinguez build secrets et runtime secrets, --mount=type=secret protège le build, pas l'exécution
  3. Les layers Docker conservent tout, multi-stage et secret mounts sont indispensables
  4. Les variables d'environnement sont plus exposées qu'on ne le croit, préférez les fichiers avec permissions restrictives
  5. Les dumps deviennent des artefacts partagés, traitez-les comme des données sensibles
  6. IMDS se protège en profondeur, IMDSv2, restrictions réseau, cloisonnement, et surtout identités de workload
  7. Les fichiers d'auth locaux sont une surface oubliée, .npmrc, kubeconfig, state Terraform peuvent tous fuiter

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn