Aller au contenu principal

Sécurité · Infrastructure · 19/10/2026 · 10 min de lecture

Sortir les secrets de GitLab CI : Vault, JWT et aucun identifiant stocké

Photo d'Emmanuel BALLERY, fondateur de x10 Emmanuel BALLERY · Directeur Technique (CTO) freelance & Architecte logiciel
Un tapis roulant passe devant un coffre mural, un bras mécanique remet un jeton ambre à un colis

Dans beaucoup de projets, les secrets de production vivent dans les variables CI/CD de GitLab : mots de passe de bases, clés de stockage objet, secrets OAuth, préfixés STAGING_ ou PRODUCTION_, saisis à la main dans l'interface au fil des années. Ça fonctionne, jusqu'au jour où l'on se demande qui peut les lire, lesquels sont partagés entre environnements, et ce qu'un job lancé depuis une branche quelconque peut en faire.

J'ai sorti de GitLab les secrets d'une application Symfony déployée sur Kubernetes, pour les ranger dans HashiCorp Vault. Cet article décrit le modèle retenu — aucun identifiant stocké dans la CI —, le code qui le met en œuvre, les pièges rencontrés, et la limite qu'aucun coffre ne lève à votre place.

Ce que les variables GitLab ne savent pas faire

Les variables CI/CD ne sont pas un mauvais outil, mais elles mélangent des rôles qu'un coffre sépare.

  • la lecture suit les droits sur le projet — toute personne qui peut modifier la configuration des variables les lit, et tout job qui s'exécute peut les recevoir, sauf à les protéger une par une
  • l'environnement n'est qu'une convention de nommage — rien n'empêche un script de staging de lire PRODUCTION_MYSQL_PASSWORD : les deux variables sont dans le même projet
  • pas d'historique exploitable — une valeur écrasée est perdue, et rien ne dit quand elle a changé
  • le masquage est fragile — il dépend du format de la valeur, et une configuration en JSON ne peut pas être masquée

Un coffre comme Vault répond à ces points par construction : chaque lecture passe par une authentification, chaque identité reçoit des politiques qui listent précisément les chemins autorisés, et le moteur KV version 2 conserve un historique des versions de chaque secret. Encore faut-il que la CI s'authentifie auprès du coffre sans réintroduire un secret dans GitLab.

Aucun identifiant dans la CI : l'authentification par JWT

La façon la plus courante de connecter une CI à Vault est l'AppRole : un couple role_id / secret_id, l'équivalent d'un login et d'un mot de passe. Le stocker dans une variable GitLab revient à déplacer le problème : on protège désormais l'accès au coffre par un secret… rangé là où étaient les autres.

GitLab sait faire mieux. Avec le mot-clé id_tokens, chaque job reçoit un jeton JWT signé par GitLab, qui décrit le job lui-même : projet, branche, type de référence, protection de la branche, environnement de déploiement. Vault vérifie la signature de ce jeton, puis compare son contenu aux conditions du rôle demandé.

deploy-staging:
  stage: deploy
  environment: staging
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  id_tokens:
    VAULT_JWT:
      aud: https://vault.example.com
  script:
    - ./deploy.sh

Le rôle côté Vault pose ses conditions dans bound_claims. Celui de staging n'accepte qu'un jeton émis pour ce projet, sur la branche principale protégée, et pour l'environnement staging :

{
  "role_type": "jwt",
  "bound_audiences": ["https://vault.example.com"],
  "bound_claims": {
    "project_id": "4242",
    "ref": "main",
    "ref_type": "branch",
    "ref_protected": "true",
    "environment": "staging"
  },
  "user_claim": "user_login",
  "token_policies": ["mon-projet_staging"],
  "token_ttl": 120,
  "token_max_ttl": 600
}

Les bénéfices sont immédiats. Aucun identifiant n'est stocké dans GitLab, donc il n'y a rien à faire tourner ni à voler. Chaque job reçoit un jeton différent, qui expire avec lui. Et un job lancé depuis une branche de fonctionnalité, ou depuis un fork, se voit refuser la connexion par Vault lui-même, quelle que soit la configuration du pipeline.

Tout repose donc sur ces conditions, et un rôle trop large annule le bénéfice. Un rôle qui accepte toute branche dont le nom commence par feature ou dev donne les secrets de staging à toute personne capable de pousser une branche. C'est le genre de rôle « prêt à l'emploi » qu'on trouve dans les exemples ou les configurations initiales : il se relit comme du code tiers, avant de lui confier quoi que ce soit.

Un environnement, un rôle, une politique, un secret

L'organisation retenue tient en une règle : chaque environnement a son rôle, sa politique et son secret, tous à son nom. Pas de niveau intermédiaire « hors production » partagé par plusieurs environnements.

path "secret/data/mon-projet/staging" {
  capabilities = ["read"]
}

La politique ne donne que la lecture, sur un seul chemin. Tout ce qui n'est pas accordé est refusé : le job de staging qui tenterait de lire le secret de production reçoit un 403. La séparation entre environnements ne repose plus sur une convention de nommage mais sur une décision du coffre.

Deux détails évitent des heures de recherche :

  • le chemin des politiques KV-2 contient data/ — l'outil vault kv lit secret/mon-projet/staging, mais l'API et les politiques écrivent secret/data/mon-projet/staging. Une politique écrite sans data/ ne donne accès à rien
  • un secret par environnement, même quand les valeurs coïncident — stockée deux fois, une valeur peut changer d'un côté sans casser l'autre

L'écriture, elle, passe par une autre identité : un AppRole de maintenance, avec les droits de création et de mise à jour sur les chemins du projet. Ses identifiants ne vont nulle part dans GitLab ; ils restent à la personne qui administre les secrets, et se saisissent sans écho pour ne pas finir dans l'historique du shell.

Le code côté déploiement

Le script de déploiement doit se connecter avec le jeton du job, lire le secret de son environnement, puis mettre les valeurs à disposition de la suite, sans jamais les écrire dans le log. Comme l'image de déploiement n'embarquait ni jq ni le client Vault, mais un Python, le cœur est un petit script en bibliothèque standard.

role = f"{project}_{environment}"
# KV-2 API path: the `data/` segment is required here, unlike with `vault kv get`.
path = f"secret/data/{project}/{environment}"
prefix = environment.upper().replace("-", "_") + "_"

# `jwt` is the job's ID token ($VAULT_JWT); Vault checks it against the role's bound_claims.
login = call("POST", f"{address}/v1/auth/jwt/login", payload={"role": role, "jwt": jwt})
token = login["auth"]["client_token"]
try:
    # KV-2 nests the secret twice: response data, then secret data.
    data = call("GET", f"{address}/v1/{path}", token=token)["data"]["data"]
finally:
    call("POST", f"{address}/v1/auth/token/revoke-self", token=token)

if not data:
    fail(f"no key in {path}")

# The output is evaluated by the shell: a key name must never become code.
for key in data:
    if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key):
        fail(f"invalid key name: {key!r}")

# Key names only, on stderr; values go to stdout and nowhere else.
log(f"{len(data)} keys: {', '.join(sorted(data))}")
print("\n".join(f"{prefix}{key}={shlex.quote(str(value))}" for key, value in sorted(data.items())))

Chaque ligne de ce script porte une décision :

  • le jeton Vault est révoqué aussitôt la lecture faite — il ne survit pas au-delà de ce dont on a besoin
  • le log ne contient que des noms de clés — c'est ce qui permet de vérifier, dans la trace du job, que le bon rôle a lu le bon nombre de clés
  • les noms de clés sont validés — la sortie est évaluée par le shell, et un nom de clé fantaisiste deviendrait du code exécuté
  • les valeurs sont échappées par shlex.quote — une valeur contenant une apostrophe ou un $ reste une valeur
  • en cas d'échec, rien n'est imprimé sur la sortie standard — connexion refusée, lecture refusée, secret vide : le script s'arrête sans livrer un jeu de valeurs partiel

Côté shell, la fonction qui l'appelle tient en quelques lignes, mais leur ordre compte :

vault_load_secrets() {
    local assignments
    if ! assignments="$(python3 vault-exports.py)"; then
        echo "reading the Vault failed" >&2
        exit 1
    fi
    eval "${assignments}"
}

La sortie est d'abord capturée, puis évaluée. Écrire directement eval "$(python3 vault-exports.py)" serait plus court, mais le code de retour d'eval masquerait l'échec du script : une lecture refusée produirait un déploiement avec des secrets vides, qui écraserait ceux du cluster. Ici, le déploiement s'arrête avant de toucher à quoi que ce soit.

Le script imprime aussi des affectations, pas des export. Les valeurs restent dans le shell de déploiement, et les processus qu'il lance, kubectl ou curl, n'en héritent pas dans leur environnement.

Les pièges de la migration

Vault fait perdre le masquage de GitLab

C'est le paradoxe de cette migration : on range les secrets dans un endroit plus sûr, et ils deviennent plus faciles à exposer. GitLab ne masque dans les logs que les variables qu'il connaît. Une valeur lue dans Vault par un script n'est pour lui que du texte : un set -x dans le script de déploiement, ou l'option CI_DEBUG_TRACE sur un job, l'affiche en clair dans la trace. La règle devient absolue : jamais de traçage verbeux sur un job qui manipule des secrets. J'ai détaillé ailleurs pourquoi un secret passé par un log est un secret exposé.

La reprise silencieuse d'une ancienne variable

Pour migrer sans interruption, les valeurs lues dans Vault écrasent les variables GitLab du même nom : on peut activer la lecture du coffre avant d'avoir supprimé quoi que ce soit dans GitLab. L'effet de bord est sournois : une clé oubliée dans Vault, mais encore présente dans GitLab, est reprise de GitLab sans le moindre message. Tout semble fonctionner, et l'on supprime ensuite les variables GitLab… pour découvrir le manque au déploiement suivant. D'où une étape de comparaison systématique, par noms de clés, avant la suppression.

Les secrets restent visibles le temps d'un appel

Une fois lues, les valeurs finissent dans un Secret Kubernetes, souvent créé par kubectl create secret generic --from-literal=…. Elles n'apparaissent pas dans le log, mais elles figurent dans les arguments de la commande, donc dans la liste des processus du conteneur de déploiement, le temps de l'appel. Le risque est faible sur un conteneur éphémère et isolé ; il mérite d'être connu, et se supprime en passant les valeurs par un fichier ou un descripteur plutôt qu'en argument.

Une écriture n'est pas une vérification

Écrire une nouvelle valeur dans le coffre ne change rien tant que l'environnement n'a pas été redéployé. Et une écriture envoyée n'est pas toujours une écriture appliquée. On vérifie donc en comparant des empreintes, côté coffre et côté cluster, sans jamais afficher de valeur.

Migrer n'est pas assainir

La migration recopie les valeurs telles quelles. Or l'inventaire fait à cette occasion montrait des secrets identiques entre staging et production, un même secret réutilisé dans quatre environnements, et des mots de passe de bases de données de trois à six caractères. Ranger ces valeurs dans un coffre ne les rend pas meilleures : une compromission du staging reste une compromission de la production, simplement mieux organisée.

Le coffre apporte le cadre — des droits par environnement, un historique, aucun identifiant dans la CI —, mais c'est la rotation qui assainit : une valeur générée par environnement, déployée, puis vérifiée. La migration est le meilleur moment pour la faire, puisqu'on touche de toute façon à chaque secret. Ne pas la faire, c'est transférer dans le coffre des secrets déjà exposés.

Conclusion

Sortir les secrets de GitLab ne demande pas une refonte : un rôle par environnement côté Vault, un id_tokens dans le pipeline, et une cinquantaine de lignes de script. Le gain est structurel : plus aucun identifiant dans la CI, une séparation des environnements garantie par le coffre plutôt que par une convention, et un historique de chaque valeur.

Mais un coffre ne corrige ni les secrets qu'on y range, ni les scripts qui les affichent. Il faut toujours décider qui lit quoi, relire les rôles fournis par défaut, bannir le traçage verbeux et profiter de la migration pour faire tourner ce qui doit l'être. Ces points complètent les fondamentaux de la sécurisation d'une application en production et d'un pipeline de déploiement fiable.

Message clé

Un coffre range les secrets, il ne les corrige pas. Le gain vient des droits par environnement et de la rotation, pas du déménagement.

Vos secrets vivent encore dans les variables de votre CI ? Un audit technique fait l'inventaire et trace le chemin de migration. Parlons-en.

Questions fréquentes

Ce qu'on me demande souvent.

Pourquoi sortir les secrets des variables GitLab ?

Parce que les variables CI/CD mélangent des rôles qu'un coffre sépare. Leur lecture suit les droits sur le projet, les environnements n'y sont séparés que par une convention de nommage, une valeur écrasée est perdue sans historique, et le masquage dépend du format de la valeur. Un coffre comme Vault authentifie chaque lecture, limite chaque identité à des chemins précis et conserve les versions de chaque secret.

Comment la CI s'authentifie-t-elle auprès de Vault sans identifiant ?

Avec le mot-clé id_tokens, GitLab fournit à chaque job un jeton JWT signé qui décrit le job : projet, branche, protection de la branche, environnement. Vault vérifie la signature et compare ces informations aux conditions du rôle demandé. Aucun identifiant n'est stocké dans GitLab, chaque jeton expire avec son job, et un job lancé depuis une branche non autorisée se voit refuser la connexion.

Les secrets lus dans Vault sont-ils masqués dans les logs GitLab ?

Non. GitLab ne masque que les variables qu'il connaît. Une valeur lue dans Vault par un script n'est pour lui que du texte : un set -x ou l'option CI_DEBUG_TRACE l'affiche en clair dans la trace du job. Il faut donc bannir le traçage verbeux sur tout job qui manipule des secrets, et faire en sorte que le script de lecture ne journalise que des noms de clés.

Migrer vers Vault suffit-il à sécuriser les secrets ?

Non. La migration recopie les valeurs telles quelles : un secret partagé entre staging et production, ou un mot de passe trop court, le reste une fois dans le coffre. Vault apporte le cadre — droits par environnement, historique, aucun identifiant dans la CI —, mais seule une rotation, une valeur générée par environnement puis vérifiée, assainit les secrets. La migration est le meilleur moment pour la faire.

Contact

Un projet ou une question sur ce sujet ?

Pas de discours commercial, pas de jargon inutile : un échange clair sur vos besoins et sur la manière dont je peux y répondre.

Démarrer votre projet arrow_forward