SécuritéVaultDevSecOpsIntermédiaire

Sécuriser les secrets CI/CD avec HashiCorp Vault

Jerry RajaonarivoMis à jour le 30 sept. 202610 min de lecture

Ce tutoriel sort les secrets des variables d'environnement CI/CD et des fichiers .env pour les ranger dans HashiCorp Vault. La CI s'authentifie avec les jetons OIDC de GitHub (ou AppRole quand l'OIDC n'est pas disponible) et reçoit des identifiants dynamiques à courte durée de vie au lieu 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.

bash
# Sautez cette ligne sur une instance `vault server -dev` : le mode dev monte déjà
# secret/ en kv-v2, et le réactiver échoue avec "path is already in use".
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-api

C'est déjà mieux qu'une variable d'environnement CI. vault kv metadata get secret/ci/payments-api affiche chaque version avec sa date de création, de suppression ou de destruction. En revanche, il n'indique pas qui l'a écrite. Pour ça, il faut le journal d'audit de Vault, désactivé tant que vous n'avez pas activé un audit device :

bash
vault audit enable file file_path=/var/log/vault_audit.log

Chaque requête et chaque réponse est alors journalisée en JSON avec l'identité appelante (entité, méthode d'authentification, policies) et le chemin. Les valeurs secrètes sont hachées en HMAC au lieu d'être écrites en clair. Activez-le avant que quoi que ce soit d'autre ne touche Vault, car sans piste d'audit vous ne pourrez pas reconstituer ce qui s'est passé après une fuite. Vérifiez que le processus Vault peut écrire à cet emplacement, et envoyez le fichier vers un stockage durable.

Le versioning aide, mais on a toujours un secret statique posé à un chemin. Le plus important est de contrôler qui peut le lire et comment la CI prouve qu'elle en a le droit.

Une policy qui accorde exactement ce dont le pipeline a besoin

Un humain qui se connecte à Vault utilise sa propre identité, via userpass, OIDC ou ce que votre organisation a mis en place. Un pipeline CI n'a pas d'identité de ce genre, et lui donner un jeton personnel (ou pire, un jeton root partagé) revient à déplacer l'identifiant dans un champ de secret CI. Quelle que soit sa méthode d'authentification, le pipeline finit par détenir un jeton rattaché à une policy comme celle-ci :

hcl
# policy: ci-payments-read.hcl
path "secret/data/ci/payments-api" {
  capabilities = ["read"]
}
 
path "secret/metadata/ci/payments-api" {
  capabilities = ["read", "list"]
}
 
# identifiants de base de données dynamiques, abordés plus bas
path "database/creds/ci-readonly" {
  capabilities = ["read"]
}
bash
vault policy write ci-payments-read ci-payments-read.hcl

GitHub Actions : l'OIDC plutôt qu'un identifiant Vault stocké

GitHub Actions peut émettre pour chaque job un jeton OIDC à courte durée de vie (un JWT signé), dont les claims indiquent le dépôt, la branche et le workflow en cours. La méthode d'authentification JWT de Vault vérifie ce jeton avec les clés publiques de GitHub et renvoie un jeton Vault. Rien n'est stocké dans les secrets GitHub, donc il n'y a aucun identifiant à faire fuiter ni à faire tourner.

bash
vault auth enable jwt
 
vault write auth/jwt/config \
  oidc_discovery_url="https://token.actions.githubusercontent.com" \
  bound_issuer="https://token.actions.githubusercontent.com"
 
vault write auth/jwt/role/ci-payments-pipeline - <<'EOF'
{
  "role_type": "jwt",
  "bound_audiences": ["https://github.com/example-org"],
  "bound_claims": {
    "repository": "example-org/payments-api",
    "ref": "refs/heads/main"
  },
  "user_claim": "repository",
  "token_policies": ["ci-payments-read"],
  "token_ttl": "15m",
  "token_max_ttl": "30m"
}
EOF

Le contrôle d'accès se fait dans bound_claims. Seuls les jobs de example-org/payments-api exécutés sur main peuvent se connecter avec ce rôle ; un fork, une branche de fonctionnalité ou un autre dépôt de l'organisation est rejeté. Avec token_ttl=15m, le jeton Vault obtenu expire peu après la fin d'un job normal. Vault a besoin d'un accès HTTPS sortant vers token.actions.githubusercontent.com pour récupérer les clés de signature de GitHub, et le runner doit pouvoir joindre Vault. En pratique, il faut donc un runner auto-hébergé ou un endpoint Vault exposé aux runners GitHub.

yaml
# .github/workflows/deploy.yml
name: Deploy payments-api
on:
  push:
    branches: [main]
 
permissions:
  id-token: write   # permet au job de demander un jeton OIDC GitHub
  contents: read
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
 
      - name: Fetch secret from Vault
        uses: hashicorp/vault-action@v4
        with:
          url: https://vault.internal.example.com:8200
          method: jwt
          role: ci-payments-pipeline
          jwtGithubAudience: https://github.com/example-org
          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 }}

jwtGithubAudience doit correspondre à bound_audiences sur le rôle Vault, sinon la connexion échoue avec une erreur d'audience. hashicorp/vault-action gère la connexion et masque les valeurs dans le log Actions. Le secret arrive comme sortie d'étape ou variable d'environnement et n'est jamais écrit sur le disque du runner. Si vous appelez directement l'API HTTP de Vault, faites pareil et redirigez la réponse vers une variable d'environnement ou l'entrée secrète d'un outil de build plutôt que vers un fichier temporaire qu'on risque d'oublier.

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".

AppRole : pour les runners qui ne savent pas faire d'OIDC

Certains systèmes CI ne savent pas émettre de jeton OIDC, un vieux Jenkins ou une flotte de runners auto-hébergés par exemple. Dans ce cas, on passe par AppRole, qui scinde l'authentification en deux. Le role_id identifie le rôle, n'est pas secret et peut figurer sans risque dans la config d'un pipeline. Le secret_id est secret, à courte durée de vie et généré à la demande.

bash
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

Avec secret_id_num_uses=1 et secret_id_ttl=10m, un secret_id est à usage unique et expire en moins de dix minutes. Il ne peut donc pas être stocké dans un champ de secret CI, puisque le premier run le consommerait et que le suivant échouerait. Un orchestrateur de confiance (le contrôleur Jenkins, un service de déploiement, tout ce qui possède déjà sa propre identité Vault) en génère plutôt un nouveau pour chaque job et le transmet sous forme de réponse enveloppée (response wrapping) :

bash
# Sur l'orchestrateur : son propre jeton doit avoir "update" sur
# auth/approle/role/ci-payments-pipeline/secret-id
WRAP_TOKEN=$(vault write -wrap-ttl=60s -field=wrapping_token \
  -f auth/approle/role/ci-payments-pipeline/secret-id)
# ...injectez $WRAP_TOKEN dans le job, jamais dans ses logs
 
# Dans le job :
SECRET_ID=$(vault unwrap -field=secret_id "$WRAP_TOKEN")
VAULT_TOKEN=$(vault write -field=token auth/approle/login \
  role_id="$VAULT_ROLE_ID" secret_id="$SECRET_ID")
export VAULT_TOKEN

Un jeton d'enveloppe ne peut être déballé qu'une seule fois. Si le unwrap du job échoue parce que le jeton a déjà servi, partez du principe que le secret_id a été intercepté en transit. Même dans ce cas, l'attaquant obtient un accès en lecture à un seul chemin Vault pendant quinze minutes, ce qui est bien moins grave qu'un accès root à la production.

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.

Secrets dynamiques : arrêter de distribuer le mot de passe lui-même

L'OIDC et AppRole règlent l'authentification à Vault. Les secrets dynamiques vont plus loin, parce que le mot de passe de la base de données ne devrait pas être statique non plus. 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.

bash
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="15m" \
  max_ttl="30m"
 
vault read database/creds/ci-readonly

Cette 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.

Ces TTL reprennent volontairement ceux du jeton CI. Un lease est rattaché au jeton qui l'a demandé. Quand ce jeton Vault expire (15 minutes, 30 au maximum) ou est révoqué, Vault révoque tous les leases qu'il a créés, quel que soit leur propre TTL. Mettre default_ttl="1h" ici promettrait une durée que Vault ne peut pas accorder à un appelant CI, et induirait en erreur la personne qui relira le rôle plus tard. Dimensionnez les TTL du rôle de base de données pour qu'ils tiennent dans la durée de vie du jeton qui les demandera. Si un job a besoin de plus, augmentez token_ttl sur le rôle d'authentification en plus du TTL de la base.

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.

bash
vault list sys/leases/lookup/database/creds/ci-readonly
 
vault lease revoke -prefix database/creds/ci-readonly
 
vault lease renew database/creds/ci-readonly/abcd1234

Renouveler un lease le prolonge jusqu'au max_ttl du rôle, mais jamais au-delà de la durée de vie du jeton qui l'a créé. Quand le TTL d'un lease arrive à échéance, ou que son jeton parent expire, Vault (avec le plugin database) supprime automatiquement le rôle Postgres correspondant, et l'identifiant disparaît complètement. Si vous soupçonnez une fuite, vault lease revoke -prefix révoque 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 tout ça avant un déploiement plus large (sautez l'étape secrets enable -path=secret, ce chemin y est déjà monté). Une fois l'audit device activé, chaque émission d'identifiant est journalisée et n'importe quel lease peut être révoqué à la demande. Les identifiants expirent en outre avant d'avoir le temps de devenir obsolètes.

ALLER PLUS LOIN

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
Me contacterRéponse sous 1 jour ouvré.

Tutoriels similaires