Aller au contenu
CI/CD & Automatisation medium

Lab 05, Sortir les secrets du code

18 min de lecture

logo gitlab

La branche de départ de ce lab contient un mot de passe écrit en clair dans le code. La chaîne postgresql://user:s3cret@db:5432/app est codée en dur dans app/config.py, et elle est aussi présente dans le .gitlab-ci.yml. Quiconque clone le dépôt ou lit les logs du pipeline peut la lire. C'est la faille de sécurité numéro un dans les pipelines CI/CD débutants. Dans ce lab, vous allez sortir ce secret du code et le gérer correctement via les variables CI/CD de GitLab.

  • Identifier un secret codé en dur dans du code Python et un pipeline CI/CD
  • Utiliser os.getenv() pour lire une valeur depuis l'environnement au lieu de la coder en dur
  • Créer une variable CI/CD dans GitLab (protégée + masquée)
  • Comprendre la différence entre variable protégée, masquée, et non protégée

Ce lab s'adresse encore aux apprenants ayant complété Lab 04. La gestion des secrets est fondamentale pour toute pipeline, introduit tôt serait prématuré. Si vous êtes en avancé, vous pouvez commencer par Lab 05 directement depuis starter/lab-05.

Les secrets codés en dur dans le code source sont une des vulnérabilités les plus fréquentes dans les projets réels. Un développeur pressé colle directement un mot de passe dans le fichier de configuration « juste pour tester », et ça finit dans un commit, dans l'historique Git, et parfois dans un dépôt public.

Situations réelles où ce lab vous aide :

  • Un audit de sécurité détecte des credentials dans l'historique Git de votre projet
  • Un développeur a poussé DATABASE_URL: "postgresql://user:s3cret@prod:5432/db" dans le CI « pour faire vite »
  • Les logs de votre pipeline affichent un token d'API en clair quand vous le passez en argument
  • Une rotation de mot de passe nécessite de modifier un fichier versionné et de pousser un commit

La branche starter/lab-05 contient volontairement la faille à corriger. Commencez par la récupérer et par constater le problème de vos propres yeux : voir le mot de passe défiler dans les logs GitLab marque bien plus qu'une explication. Le pipeline de cette branche fonctionne, rien n'est cassé, c'est précisément ce qui rend ce défaut si fréquent en projet réel.

  1. Passez sur la branche de départ

    Fenêtre de terminal
    cd pipeline-craft
    git checkout starter/lab-05
  2. Observez les secrets en place

    Ouvrez app/config.py :

    class Settings:
    database_url: str = os.getenv(
    "DATABASE_URL", "postgresql://user:s3cret@db:5432/app"
    )

    Et regardez le .gitlab-ci.yml :

    variables:
    DATABASE_URL: "postgresql://user:s3cret@db:5432/app"

    Le mot de passe s3cret est visible dans deux endroits. Si ce dépôt était public, n'importe qui pourrait le lire.

  3. Poussez pour déclencher le pipeline

    Fenêtre de terminal
    git push origin starter/lab-05

    Dans les logs du job pytest, cherchez la ligne Testing with DATABASE_URL=..., vous verrez le mot de passe s'afficher en clair dans les logs GitLab.

Il y a deux problèmes distincts dans cette branche.

Le premier est dans app/config.py. La valeur par défaut de DATABASE_URL contient un mot de passe : "postgresql://user:s3cret@db:5432/app". En Python, une valeur par défaut dans le code source est acceptable pour le développement local (une valeur sans conséquence), mais elle ne doit jamais contenir un vrai credential. Si demain vous mettez ce projet en production avec cette valeur, et que quelqu'un accède à votre code, il a le mot de passe.

Le second est dans .gitlab-ci.yml. La variable DATABASE_URL est déclarée avec sa valeur en clair dans le fichier de configuration. Cette valeur s'affiche dans les logs. Elle est dans le dépôt Git, donc dans l'historique, donc potentiellement dans des forks et des archives.

La bonne pratique est de ne jamais stocker de secret dans un fichier versionné. Les valeurs sensibles doivent être déclarées dans GitLab (Settings > CI/CD > Variables) et injectées automatiquement dans les jobs.

Le nettoyage se fait en trois temps, dans cet ordre : le code Python, puis le pipeline, puis la déclaration de la variable dans GitLab. Chaque étape commence par des questions à traiter par vous-même ; le résultat attendu est replié juste en dessous, à ouvrir une fois votre proposition écrite. Traiter les étapes dans le désordre casse le pipeline en cours de route, car le job perdrait sa variable avant qu'elle existe côté GitLab.

Ouvrez app/config.py. Vous verrez que la valeur par défaut de DATABASE_URL contient un mot de passe : "postgresql://user:s3cret@db:5432/app".

À vous de proposer :

  1. Par quelle chaîne allez-vous remplacer ce mot de passe dans la valeur par défaut ?

    • 🔍 Indice : Utilisez une base de données qui fonctionne sans configuration (SQLite) et un chemin local inoffensif
    • 🔍 Indice 2 : Format SQLite : sqlite:///chemin/vers/fichier.db
  2. Créez un fichier .env.example à la racine du projet. Qu'allez-vous y mettre ?

    • 🔍 Indice : Listez les variables d'environnement nécessaires
    • 🔍 Indice 2 : Les valeurs doivent être des exemples sans secrets réels
  3. Comment allez-vous documenter ce changement aux autres développeurs ?

    • 🔍 Indice : Ajoutez un commentaire au début de config.py expliquant que les vraies valeurs doivent être en variables d'environnement
👉 Voir le résultat attendu de l'étape 1 (masqué)

1️⃣ Remplacer la valeur par défaut dans app/config.py

Section intitulée « 1️⃣ Remplacer la valeur par défaut dans app/config.py »

Le fichier ne perd pas sa valeur par défaut, elle change simplement de nature : plus aucun identifiant réel, seulement de quoi démarrer en local.

"""Configuration de l'application : lit les variables d'environnement."""
import os
class Settings:
"""Paramètres de l'application, lus depuis l'environnement."""
app_version: str = os.getenv("APP_VERSION", "0.1.0")
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./test.db")
debug: bool = os.getenv("DEBUG", "false").lower() == "true"
settings = Settings()

La valeur par défaut "sqlite:///./test.db" est complètement inoffensive :

  • Fonctionne en développement local sans aucune configuration
  • SQLite crée un fichier dans le répertoire courant
  • Pas de serveur, pas d'authentification, pas de mot de passe
  • En CI/CD et en production, GitLab injectera la vraie valeur via la variable DATABASE_URL

À la racine du projet, créez ce fichier :

Fenêtre de terminal
# Copier ce fichier en .env et adapter les valeurs
# En CI/CD : configurer via Settings > CI/CD > Variables (Protected + Masked)
APP_VERSION=0.1.0
DATABASE_URL=postgresql://user:changeme@localhost:5432/app
DEBUG=false

Remarque importante :

  • Versionnez .env.example (pas de secrets)
  • Ignorez .env (confidentiel, ajoutez à .gitignore s'il existe)

Ajoutez un commentaire en première ligne de config.py :

"""
Configuration de l'application : lit les variables d'environnement.
Les secrets (DATABASE_URL, tokens API, etc.) doivent JAMAIS être codés en dur.
Les valeurs par défaut ici sont des exemples inoffensifs pour le développement local.
En production et en CI/CD, les vraies valeurs sont injectées via des variables protégées.
Voir .env.example pour la liste des variables requises.
"""

Vous allez supprimer le secret du .gitlab-ci.yml. Deux modifications à faire : une dans le bloc variables: global, et une dans le script du job pytest (une ligne de debug).

À vous de proposer :

  1. Qu'allez-vous supprimer du bloc variables: global ?

    • 🔍 Indice : Repérez la ligne qui contient DATABASE_URL et le mot de passe
  2. Qu'allez-vous supprimer du script du job pytest ?

    • 🔍 Indice : Cherchez une ligne qui commence par echo et affiche DATABASE_URL
    • 🔍 Indice 2 : Règle générale : ne jamais logger les variables sensibles, même "par accident"
  3. Qu'allez-vous garder dans variables: ?

    • 🔍 Indice : La seule variable globale utile est PIP_CACHE_DIR (ce n'est pas un secret)
👉 Voir le résultat attendu de l'étape 2 (masqué)

Une variable déclarée dans .gitlab-ci.yml est lisible par tous ceux qui ont accès au dépôt, et elle reste dans l'historique Git même après suppression. Celle-ci disparaît donc du fichier pour être redéclarée côté GitLab.

Avant :

variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
DATABASE_URL: "postgresql://user:s3cret@db:5432/app"

Après :

variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"

PIP_CACHE_DIR reste car ce n'est pas un secret, c'est juste un chemin.

2️⃣ Supprimer la ligne de debug dans le job pytest

Section intitulée « 2️⃣ Supprimer la ligne de debug dans le job pytest »

Cette ligne de trace n'apporte rien aux tests et recopie la valeur de la variable dans un journal conservé bien plus longtemps que le job lui-même.

Avant :

pytest:
script:
- echo "Testing with DATABASE_URL=$DATABASE_URL"
- pytest -v --junitxml=report.xml

Après :

pytest:
script:
- pytest -v --junitxml=report.xml

Ne loggez rien qui puisse contenir une valeur sensible, même "juste pour debug". Les logs sont souvent consultables par d'autres personnes et conservés longtemps.

Étape 3, À vous de déclarer la variable dans GitLab

Section intitulée « Étape 3, À vous de déclarer la variable dans GitLab »

Vous allez créer une variable CI/CD dans GitLab, avec deux protections essentielles : Protected et Masked.

À vous de proposer :

  1. Où allez-vous créer cette variable dans l'interface GitLab ?

    • 🔍 Indice : Menu Settings, puis CI/CD, puis Variables
  2. Quels champs allez-vous remplir ?

    • 🔍 Indice : Key, Value, Type, Protected, Masked
  3. Pourquoi cocher la case Protected ?

    • 🔍 Indice : Empêcher une branche malveillante de voler la variable
  4. Pourquoi cocher Masked ?

    • 🔍 Indice : Si quelqu'un oublie un echo, GitLab remplace la valeur par [MASKED] dans les logs
  5. Quelle valeur allez-vous mettre dans le champ Value ?

    • 🔍 Indice : Une valeur de test, pas le vrai mot de passe de production
    • 🔍 Indice 2 : Format : postgresql://user:changeme@localhost:5432/app
👉 Voir le résultat attendu de l'étape 3 (masqué)

Les variables se déclarent au niveau du projet, dans votre fork et non dans le dépôt d'origine sur lequel vous n'avez aucun droit.

  1. Allez dans votre fork sur gitlab.com
  2. Menu gauche : Settings → CI/CD → Variables
  3. Cliquez sur Add variable

Renseignez les champs comme indiqué ci-dessous. Une fois la variable enregistrée avec Masked, sa valeur n'est plus consultable dans l'interface : pour la changer, il faut la ressaisir entièrement.

ChampValeur
KeyDATABASE_URL
Valuepostgresql://user:changeme@localhost:5432/app
TypeVariable
Protected✅ Coché
Masked✅ Coché
Expand variable reference✅ Laissez coché (défaut)

Cliquez sur Add variable.

Protected : La variable n'est injectée que dans les jobs qui tournent sur des branches/tags protégés (ex : main). Cela empêche un développeur malveillant de :

  • Créer une branche steal-secrets
  • Ajouter un job echo $DATABASE_URL
  • Voir le secret dans les logs publics

Masked : Si quelqu'un ajoute accidentellement un echo $DATABASE_URL dans le pipeline après votre nettoyage, GitLab remplace automatiquement à l'affichage :

✗ Affichage avant → echo postgresql://user:changeme@localhost:5432/app
✓ Affichage après → echo [MASKED]

Les deux se complètent : Protected décide si la variable entre dans le job, Masked décide ce qui s'affiche si elle en ressort. La première empêche l'accès, la seconde rattrape une fuite dans les journaux.

Voici l'état attendu des trois fichiers après les trois étapes, à comparer avec le vôtre plutôt qu'à recopier.

📄 Voir la solution complète du Lab 05 (masquée)

Aucun identifiant réel ne subsiste : les trois paramètres se lisent dans l'environnement, avec des valeurs de repli sans conséquence.

"""Configuration de l'application : lit les variables d'environnement."""
import os
class Settings:
"""Paramètres de l'application, lus depuis l'environnement."""
app_version: str = os.getenv("APP_VERSION", "0.1.0")
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./test.db")
debug: bool = os.getenv("DEBUG", "false").lower() == "true"
settings = Settings()

Le bloc variables: ne conserve que PIP_CACHE_DIR, et le job pytest a perdu sa ligne de trace. DATABASE_URL n'apparaît plus nulle part dans le fichier : elle arrive désormais de la configuration du projet.

stages:
- lint
- test
- build
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
ruff-lint:
stage: lint
image: python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
cache:
key:
files:
- requirements-dev.txt
paths:
- .pip-cache/
policy: pull
before_script:
- pip install ruff
script:
- ruff check app/ tests/
pytest:
stage: test
image: python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
cache:
key:
files:
- requirements-dev.txt
paths:
- .pip-cache/
before_script:
- pip install -r requirements-dev.txt
script:
- pytest -v --junitxml=report.xml
artifacts:
when: always
paths:
- report.xml
reports:
junit: report.xml
expire_in: 7 days
docker-build:
stage: build
image: docker:27@sha256:aa3df78ecf320f5fafdce71c659f1629e96e9de0968305fe1de670e0ca9176ce
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:latest

Ce fichier est versionné et sert de contrat : il liste les variables attendues sans en livrer les valeurs. Un nouvel arrivant sait quoi renseigner sans avoir à lire tout le code.

Fenêtre de terminal
# Copier ce fichier en .env et adapter les valeurs
# En CI/CD : configurer via Settings > CI/CD > Variables (Protected + Masked)
APP_VERSION=0.1.0
DATABASE_URL=postgresql://user:changeme@localhost:5432/app
DEBUG=false

Le correctif ne vaut que s'il est constaté dans les journaux du pipeline, pas seulement dans les fichiers. Poussez les trois fichiers modifiés, puis relisez le job pytest en cherchant ce qui devrait avoir disparu.

  1. Committez les modifications

    Fenêtre de terminal
    git add app/config.py .env.example .gitlab-ci.yml
    git commit -m "fix: remove hardcoded credentials, use CI/CD variables"
    git push origin starter/lab-05
  2. Observez les logs du job pytest

    Cherchez la ligne qui affichait DATABASE_URL=postgresql://user:s3cret@.... Elle a disparu, la variable n'est plus logguée, et même si elle l'était, [MASKED] s'afficherait à la place.

  3. Vérification rapide : cherchez s3cret dans les logs

    Si vous ne trouvez pas s3cret nulle part dans les logs, c'est bon signe. Le secret ne transite plus en clair.

Le lab est réussi quand les cinq points ci-dessous sont vrais en même temps. Le dernier est le seul qui prouve réellement quelque chose : les quatre autres décrivent l'intention, celui-là constate le résultat côté GitLab.

  • app/config.py : plus de mot de passe dans la valeur par défaut
  • .gitlab-ci.yml : DATABASE_URL supprimée du bloc variables:
  • .env.example créé avec des valeurs d'exemple (sans vrai secret)
  • La variable DATABASE_URL est déclarée dans Settings > CI/CD > Variables (Protected + Masked)
  • Les logs du pipeline ne contiennent plus s3cret

Les trois blocages ci-dessous découlent du même mécanisme : une variable Protected n'est injectée que dans les jobs qui tournent sur une branche ou un tag protégés. Comme starter/lab-05 n'est pas une branche protégée, le comportement observé pendant le lab diffère de celui attendu sur main.

SymptômeCauseSolution
La variable ne s'injecte pas dans le jobVariable déclarée comme Protected mais la branche n'est pas protégéeSoit déprotéger la variable pour le test, soit protéger la branche
[MASKED] n'apparaît pas malgré le masquageLa valeur est trop courte (< 8 caractères) ou contient des caractères spéciaux non supportésChoisir une valeur de test plus longue pour tester le masquage
Le pipeline échoue avec DATABASE_URL non définieLa variable est Protected et le job tourne sur une branche non protégéeTester avec une variable non-protected en premier
  • git log -p --all -S "s3cret" : cette commande cherche dans tout l'historique Git les commits qui ont ajouté ou supprimé le mot s3cret. Si votre dépôt est public, un secret qui a traversé l'historique est compromis même après suppression, il faut changer le mot de passe.
  • Comparez avec solution/lab-05 : git diff origin/starter/lab-05..origin/solution/lab-05 montre exactement les modifications attendues.
  • Un secret ne doit jamais figurer dans un fichier versionné, ni dans le code, ni dans le CI, ni dans les commentaires
  • os.getenv("VAR", "valeur_par_defaut") est le pattern correct en Python : la valeur par défaut doit être inoffensive (SQLite, localhost, false)
  • Protected protège contre le vol de la variable via une branche malveillante. Masked protège contre l'affichage accidentel dans les logs. Utilisez les deux.
  • Un .env.example versionné documente les variables nécessaires sans les exposer

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