Sécuriser les secrets CI/CD avec HashiCorp Vault
Ce tutoriel sort les secrets des variables d'environnement CI/CD et des fichiers .env pour les stocker dans HashiCorp Vault, avec une authentification AppRole et des identifiants dynamiques à courte durée de vie à la place de secrets statiques.
Activer KV v2 et écrire votre premier secret versionné
Le moteur de secrets KV v2 de Vault est le point de départ : un stockage clé-valeur versionné, où écraser un secret ne détruit pas son historique et où vous pouvez revenir en arrière.
vault secrets enable -path=secret kv-v2
vault kv put secret/ci/payments-api \
api_key="sk_live_..." \
webhook_secret="whsec_..."
vault kv get secret/ci/payments-api
vault kv get -version=1 secret/ci/payments-apiC'est déjà mieux qu'une variable d'environnement CI : vault kv metadata get secret/ci/payments-api vous montre exactement quand chaque version a été créée et par qui. Mais ça reste un secret statique posé à un chemin donné : le vrai travail consiste à contrôler qui peut le lire, et c'est là qu'intervient AppRole.
Authentification AppRole : plus de jeton statique géré à la main dans la CI
Un humain qui se connecte à Vault utilise sa propre identité, userpass, OIDC, peu importe ce que votre organisation utilise. Un pipeline CI n'est pas un humain, et lui donner un jeton personnel (ou pire, un jeton root partagé) ne fait que déplacer l'identifiant vers un champ de secret CI.
AppRole scinde l'authentification en deux éléments : un role_id (non secret, identifie le rôle, sans risque à intégrer dans la config d'un pipeline) et un secret_id (secret, à courte durée de vie, généré à la demande). La CI récupère un secret_id au début d'une exécution, échange les deux contre un jeton Vault qui expire une fois le job terminé.
# policy: ci-payments-read.hcl
path "secret/data/ci/payments-api" {
capabilities = ["read"]
}
path "secret/metadata/ci/payments-api" {
capabilities = ["read", "list"]
}vault policy write ci-payments-read ci-payments-read.hcl
vault auth enable approle
vault write auth/approle/role/ci-payments-pipeline \
token_policies="ci-payments-read" \
token_ttl=15m \
token_max_ttl=30m \
secret_id_ttl=10m \
secret_id_num_uses=1
vault read auth/approle/role/ci-payments-pipeline/role-id
vault write -f auth/approle/role/ci-payments-pipeline/secret-idsecret_id_num_uses=1 signifie que ce secret_id est consommé dès que la CI l'utilise. Même s'il fuitait dans un log de build, il serait déjà mort. token_ttl=15m signifie que le jeton Vault obtenu ne vaut plus rien au-delà de la durée normale d'un job.
N'utilisez jamais le jeton root initial de Vault pour un usage quotidien, la CI y compris. Il contourne toutes les policies que vous écrivez. Les jetons root existent pour l'amorçage initial et le break-glass d'urgence : générés à la demande, révoqués immédiatement après, jamais stockés, pas même dans Vault.
Récupérer le secret dans GitHub Actions sans jamais l'écrire sur disque ni dans les logs
Le secret_id devrait provenir d'un endroit que votre plateforme CI traite comme protégé : une variable CI masquée, ou mieux, un secret généré à chaque run par une étape d'amorçage de confiance. Même un secret CI permanent a désormais un rayon d'action limité à "lire un chemin Vault pendant quinze minutes", pas "accès root à la production".
- name: Fetch secret from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal.example.com:8200
method: approle
roleId: ${{ vars.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets: |
secret/data/ci/payments-api api_key | PAYMENTS_API_KEY ;
secret/data/ci/payments-api webhook_secret | PAYMENTS_WEBHOOK_SECRET
- name: Deploy
run: ./scripts/deploy.sh
env:
PAYMENTS_API_KEY: ${{ env.PAYMENTS_API_KEY }}
PAYMENTS_WEBHOOK_SECRET: ${{ env.PAYMENTS_WEBHOOK_SECRET }}hashicorp/vault-action gère la connexion AppRole et masque les valeurs dans le log Actions automatiquement ; le secret arrive comme sortie d'étape / variable d'environnement, jamais comme fichier sur le disque du runner. La même règle s'applique si vous appelez directement l'API HTTP de Vault : redirigez la réponse vers une variable d'environnement ou l'entrée secrète d'un outil de build, pas vers un fichier temporaire que vous oublierez de nettoyer.
Résistez à l'envie de faire echo $PAYMENTS_API_KEY même temporairement en déboguant un pipeline. La plupart des systèmes CI ne masquent que les valeurs déjà connues : une valeur fraîchement récupérée depuis Vault dans ce même job n'est souvent pas encore enregistrée comme chaîne à masquer, et la sortie d'erreur d'une étape en échec peut déverser le contexte d'environnement tel quel. Le débogage sûr consiste à vérifier que la variable n'est pas vide : test -n "$PAYMENTS_API_KEY" && echo "set".
Secrets dynamiques : arrêter de distribuer le mot de passe lui-même
AppRole résout l'authentification à Vault. Les secrets dynamiques résolvent un problème plus large : le mot de passe de base de données lui-même ne devrait pas être statique. Avec le moteur de secrets database, Vault crée un utilisateur de base de données à courte durée de vie pour chaque exécution CI, adossé à un vrai CREATE ROLE sur votre instance Postgres.
vault secrets enable database
vault write database/config/payments-db \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/payments" \
allowed_roles="ci-readonly" \
username="vault-admin" \
password="..."
vault write database/roles/ci-readonly \
db_name=payments-db \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="4h"
vault read database/creds/ci-readonlyCette dernière commande est ce que la CI exécute au lieu de lire un mot de passe stocké ; elle récupère un nom d'utilisateur et un mot de passe qui n'existent que pour ce bail (lease), avec exactement les droits définis dans creation_statements.
Leases et révocation : faire en sorte qu'"expiré" veuille vraiment dire quelque chose
Chaque secret dynamique délivré par Vault vient avec un lease, un objet suivi, doté d'un TTL que Vault gère lui-même, si bien que vous pouvez le voir et le contrôler directement, plutôt que de faire confiance au pipeline pour s'en défaire de lui-même.
vault list sys/leases/lookup/database/creds/ci-readonly
vault lease revoke -prefix database/creds/ci-readonly
vault lease renew database/creds/ci-readonly/abcd1234Si le TTL d'un lease expire, Vault (avec le plugin database) supprime automatiquement le rôle Postgres correspondant : l'identifiant est purement et simplement supprimé, et non simplement révoqué. Et si vous soupçonnez une fuite, vault lease revoke -prefix tue immédiatement tous les identifiants délivrés sous ce chemin.
Ce que ça change concrètement
Une seule instance Vault en dev-mode suffit pour essayer ça avant de le déployer plus largement. Ce qui change, c'est le mode de défaillance : chaque émission est journalisée, chaque lease est révocable à la demande, et rien n'y reste immobile assez longtemps pour devenir périmé.
Envie de faire tourner ça en production ?
Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.
Ce tutoriel
- Concepts clés et architecture principale
- Extraits de code illustratifs
- Le raisonnement derrière chaque décision
Mentorat individuel
- Des sessions de travail sur votre propre environnement
- Des réponses directes aux cas particuliers que vous rencontrez
- Un retour sur votre implémentation réelle
- Un accompagnement continu pendant que vous la construisez