
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.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- 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
Quel lab commencer ?
Section intitulée « Quel lab commencer ? »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.
Dans quel contexte ?
Section intitulée « Dans quel contexte ? »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
Prérequis
Section intitulée « Prérequis »- Lab 04 complété, ou partir directement de la branche
starter/lab-05 - Avoir lu Variables GitLab CI/CD (conseillé)
Point de départ
Section intitulée « Point de départ »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.
-
Passez sur la branche de départ
Fenêtre de terminal cd pipeline-craftgit checkout starter/lab-05 -
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
s3cretest visible dans deux endroits. Si ce dépôt était public, n'importe qui pourrait le lire. -
Poussez pour déclencher le pipeline
Fenêtre de terminal git push origin starter/lab-05Dans les logs du job
pytest, cherchez la ligneTesting with DATABASE_URL=..., vous verrez le mot de passe s'afficher en clair dans les logs GitLab.
Le problème
Section intitulée « Le problème »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.
L'exercice
Section intitulée « L'exercice »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.
Étape 1, À vous de nettoyer le code Python
Section intitulée « Étape 1, À vous de nettoyer le code Python »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 :
-
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
-
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
-
Comment allez-vous documenter ce changement aux autres développeurs ?
- 🔍 Indice : Ajoutez un commentaire au début de
config.pyexpliquant que les vraies valeurs doivent être en variables d'environnement
- 🔍 Indice : Ajoutez un commentaire au début de
👉 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
2️⃣ Créer .env.example pour la documentation
Section intitulée « 2️⃣ Créer .env.example pour la documentation »À la racine du projet, créez ce fichier :
# Copier ce fichier en .env et adapter les valeurs# En CI/CD : configurer via Settings > CI/CD > Variables (Protected + Masked)APP_VERSION=0.1.0DATABASE_URL=postgresql://user:changeme@localhost:5432/appDEBUG=falseRemarque importante :
- Versionnez
.env.example(pas de secrets) - Ignorez
.env(confidentiel, ajoutez à.gitignores'il existe)
3️⃣ Documenter dans le code
Section intitulée « 3️⃣ Documenter dans le code »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."""Étape 2, À vous de nettoyer le pipeline CI
Section intitulée « Étape 2, À vous de nettoyer le pipeline CI »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 :
-
Qu'allez-vous supprimer du bloc
variables:global ?- 🔍 Indice : Repérez la ligne qui contient
DATABASE_URLet le mot de passe
- 🔍 Indice : Repérez la ligne qui contient
-
Qu'allez-vous supprimer du script du job
pytest?- 🔍 Indice : Cherchez une ligne qui commence par
echoet afficheDATABASE_URL - 🔍 Indice 2 : Règle générale : ne jamais logger les variables sensibles, même "par accident"
- 🔍 Indice : Cherchez une ligne qui commence par
-
Qu'allez-vous garder dans
variables:?- 🔍 Indice : La seule variable globale utile est
PIP_CACHE_DIR(ce n'est pas un secret)
- 🔍 Indice : La seule variable globale utile est
👉 Voir le résultat attendu de l'étape 2 (masqué)
1️⃣ Supprimer DATABASE_URL du bloc variables:
Section intitulée « 1️⃣ Supprimer DATABASE_URL du bloc variables: »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.xmlAprès :
pytest: script: - pytest -v --junitxml=report.xmlNe 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 :
-
Où allez-vous créer cette variable dans l'interface GitLab ?
- 🔍 Indice : Menu Settings, puis CI/CD, puis Variables
-
Quels champs allez-vous remplir ?
- 🔍 Indice : Key, Value, Type, Protected, Masked
-
Pourquoi cocher la case Protected ?
- 🔍 Indice : Empêcher une branche malveillante de voler la variable
-
Pourquoi cocher Masked ?
- 🔍 Indice : Si quelqu'un oublie un
echo, GitLab remplace la valeur par[MASKED]dans les logs
- 🔍 Indice : Si quelqu'un oublie un
-
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é)
Navigation et création
Section intitulée « Navigation et création »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.
- Allez dans votre fork sur gitlab.com
- Menu gauche : Settings → CI/CD → Variables
- Cliquez sur Add variable
Remplir le formulaire
Section intitulée « Remplir le formulaire »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.
| Champ | Valeur |
|---|---|
| Key | DATABASE_URL |
| Value | postgresql://user:changeme@localhost:5432/app |
| Type | Variable |
| Protected | ✅ Coché |
| Masked | ✅ Coché |
| Expand variable reference | ✅ Laissez coché (défaut) |
Cliquez sur Add variable.
Explications de sécurité
Section intitulée « Explications de sécurité »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.
Le fichier complet
Section intitulée « Le fichier complet »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)
app/config.py
Section intitulée « app/config.py »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().gitlab-ci.yml
Section intitulée « .gitlab-ci.yml »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.env.example
Section intitulée « .env.example »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.
# Copier ce fichier en .env et adapter les valeurs# En CI/CD : configurer via Settings > CI/CD > Variables (Protected + Masked)APP_VERSION=0.1.0DATABASE_URL=postgresql://user:changeme@localhost:5432/appDEBUG=falsePousser et vérifier
Section intitulée « Pousser et vérifier »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.
-
Committez les modifications
Fenêtre de terminal git add app/config.py .env.example .gitlab-ci.ymlgit commit -m "fix: remove hardcoded credentials, use CI/CD variables"git push origin starter/lab-05 -
Observez les logs du job
pytestCherchez 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. -
Vérification rapide : cherchez
s3cretdans les logsSi vous ne trouvez pas
s3cretnulle part dans les logs, c'est bon signe. Le secret ne transite plus en clair.
Vérification
Section intitulée « Vérification »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_URLsupprimée du blocvariables: -
.env.examplecréé avec des valeurs d'exemple (sans vrai secret) - La variable
DATABASE_URLest déclarée dans Settings > CI/CD > Variables (Protected + Masked) - Les logs du pipeline ne contiennent plus
s3cret
Pièges fréquents
Section intitulée « Pièges fréquents »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ôme | Cause | Solution |
|---|---|---|
| La variable ne s'injecte pas dans le job | Variable déclarée comme Protected mais la branche n'est pas protégée | Soit déprotéger la variable pour le test, soit protéger la branche |
[MASKED] n'apparaît pas malgré le masquage | La valeur est trop courte (< 8 caractères) ou contient des caractères spéciaux non supportés | Choisir une valeur de test plus longue pour tester le masquage |
Le pipeline échoue avec DATABASE_URL non définie | La variable est Protected et le job tourne sur une branche non protégée | Tester avec une variable non-protected en premier |
Pour aller plus loin
Section intitulée « Pour aller plus loin »git log -p --all -S "s3cret": cette commande cherche dans tout l'historique Git les commits qui ont ajouté ou supprimé le mots3cret. 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-05montre exactement les modifications attendues.
À retenir
Section intitulée « À retenir »- 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.exampleversionné documente les variables nécessaires sans les exposer