Coulisses

Trois clics pour rotater un secret

Comment j'ai fait passer la rotation d'un secret de vingt-cinq minutes à vingt-cinq secondes — un coffre Vaultwarden, un CLI qui résout à la volée, un badge ambré dans le dashboard. Et pourquoi enlever la friction d'un geste change le geste lui-même.

Coffre-fort enchaîné sous des dizaines de cadenas dépareillés ; au premier plan, un gros cadenas doré et sa clé en lumière — celui qui ouvre tout.
Une trentaine de secrets, autant de cadenas recopiés partout. Désormais : un coffre, une clé.

Le 15 mai 2026 au matin, j'ai ouvert le dashboard et un petit chiffre ambré était apparu à côté de l'onglet OPSEC dans la sidebar. Pas une alerte rouge clignotante, pas un mail dans la boîte. Juste un « 1 ». Au survol : « 1 secret à rotater (en retard ou bientôt) ».

J'ai cliqué.

La page m'a affiché la liste, regroupée par criticité. Le secret en question — SONAR_TOKEN, échéance dans 5 jours — était en tête, avec un badge ambré « bientôt ». À droite, deux boutons : « Rotater » et « + ». J'ai cliqué « Rotater ». Le coffre s'est ouvert sur l'item, master password, je colle la nouvelle valeur, sauvegarde. Ciao.

Total : trois clics, vingt-cinq secondes montre en main. Et le système entier reprend la nouvelle valeur tout seul.

Comment j'en suis arrivé là, et pourquoi enlever la friction d'un geste change le geste lui-même — c'est l'histoire des dernières semaines.

L'avant — pourquoi je ne rotais jamais

Il y a deux mois, rotater SONAR_TOKEN se passait comme ça : ouvrir le .env du repo hub-deploy, retrouver la ligne, faire ce qu'il faut côté sonarqube pour révoquer + générer un nouveau, le coller dans le .env, puis se rappeler de la trentaine de repos GitHub qui consommaient ce token en CI et faire gh secret set SONAR_TOKEN sur chacun. Une procédure entière, écrite dans un SECRETS-REVOCATION.md de hub-deploy, qui prenait facilement vingt-cinq minutes si je faisais attention. Quarante si j'oubliais un repo, ce qui arrivait.

Ce fichier liste treize secrets de la plate-forme — clés SSH, mots de passe harbor et NPM, tokens GitHub, secrets JWT, OAuth Google — avec, pour chacun, sa procédure de rotation manuelle. C'est un document de SOP, écrit en avril. Sa simple existence dit l'inconfort : on a inventé une procédure papier pour gérer des secrets qui devraient se faire oublier.

Résultat : je ne rotais jamais. La SOP était là, je ne l'exécutais pas. Ce n'est pas que je n'avais pas conscience du risque — j'avais juste autre chose à faire. Et l'autre chose gagnait à chaque fois.

Le coût caché n'est pas le fichier .env. Le coût caché, c'est sa propagation. Chaque secret existe en triple : dans .env, dans le secret k8s du namespace semantic-hub, et dans le secret GitHub Actions de chaque repo qui le consomme en CI. Trois copies, trois sources de divergence possibles. Le 13 mai, j'ai régénéré un SONAR_TOKEN côté serveur et l'ai poussé dans tous les repos. J'ai oublié hub-deploy/.env. Pendant deux jours, mes scans locaux ont tapé sur l'ancien token — une demi-journée perdue à diagnostiquer un effet de bord pourtant prévisible. Le système n'avait pas de point unique de vérité ; il avait des points de vérité parallèles qui dérivent. Et pour ce genre de geste, l'arbitrage est sans appel : ce n'est pas la motivation qui manque, c'est le rapport coût/bénéfice ressenti à l'instant t. Vingt-cinq minutes maintenant contre un risque hypothétique de fuite plus tard — c'est toujours la fuite hypothétique qui perd.

La plomberie — Vaultwarden + bw CLI

Tout le travail des dernières semaines tenait à faire disparaître ce coût. Pas à le réduire — à le supprimer. La cible : zéro secret en clair dans les dépôts, et zéro secret en clair dans .env. La brique : un Vaultwarden self-hosté nommé hub-vault, et un CLI bw qui résout les valeurs à la volée en CI.

J'ai choisi Vaultwarden (le serveur Bitwarden-compatible écrit en Rust) plutôt que HashiCorp Vault. Vaultwarden parle l'API Bitwarden. Toute la chaîne client est gratuite et déjà mature : extension navigateur, clients mobiles, applications desktop, et surtout un CLI officiel — bw — qui sait exactement ce dont j'ai besoin pour la CI : se logger via API key, déverrouiller le vault avec le master password, lire la valeur d'un secret par son nom. Pas de Sealed Secrets, pas d'ESO en première intention, pas de pattern custom : le CLI fait le travail.

L'image upstream officielle vaultwarden/server pèse une cinquantaine de Mo. Elle est en prod sur k3s depuis le 14 mai, pinned sur le node dams via tier=dev-box, avec un postgres dédié et un PVC local-path pour les attachments. Le coffre est exposé en HTTPS via mon reverse-proxy, avec un certificat Let's Encrypt. Un CronJob k8s fait un pg_dump quotidien à 03h00 UTC vers un PVC dédié, rétention 7 jours.

Tous les secrets de la plateforme vivent là, avec des champs custom standardisés : criticité, intervalle de rotation, liste des services qui les consomment, lien vers la procédure. Une trentaine d'items, structurés. Plus aucun secret en clair dans la trentaine de repos écosystème : à la place, trois secrets GitHub minimaux (BW_CLIENTID, BW_CLIENTSECRET, BW_PASSWORD) qui permettent au CLI bw de lire la vraie valeur dans le coffre au moment du run. La rotation d'un secret = coller la nouvelle valeur sur l'item du coffre, et la CI suivante la prend automatiquement. Ces trois-là sont la racine du système — la seule chose qu'on ne peut pas migrer dans Vaultwarden, puisqu'ils servent justement à y entrer.

Deux boucles de fond gardent l'ensemble cohérent, avec des rôles distincts. Le syncer synchronise : toutes les quinze minutes, il pousse les métadonnées des items hub-vault vers la base de hub-projects et propage les valeurs fraîchement rotées vers les secrets k8s consommés au runtime. Un second passage, plus lent — toutes les six heures — recalcule le statut de chaque secret : à jour, à rotater bientôt, ou en retard. L'un doit aller vite, une valeur circule ; l'autre peut prendre son temps, c'est un suivi, pas une alarme.

Quatre pièges, capitalisés

Cette section est pour qui veut reproduire le pattern ; les autres peuvent sauter à la partie dashboard.

Le binaire, pas npm. Les runners self-hosted GitHub Actions ne sont pas une distribution Linux complète. Pas de Node.js, pas de npm. Si on lance npm install -g @bitwarden/cli, on prend un exit 127 sur npm: command not found. Le réflexe « install Node first » ajoute trois minutes par job. Le bon réflexe est curl + unzip du binaire bw direct depuis les releases Bitwarden — exactement le pattern qu'on utilise déjà pour sonar-scanner dans la même CI.

Le rate limit. Vaultwarden plafonne /identity/connect/token à dix burst toutes les soixante secondes par défaut. J'ai poussé tous les repos en parallèle pour valider la migration. Au troisième round, neuf CI sont tombées sur Rate limit exceeded. Try again later. statusCode: 429. Solution : remonter LOGIN_RATELIMIT_MAX_BURST au-dessus du nombre de CI qui se loguent en parallèle (par exemple 50), puis figer la valeur dans le chart hub-vault.

La session bw qui survit. Le runner self-hosted réutilise la même machine entre deux runs. Si le précédent a oublié de faire bw logout, le suivant ne peut pas faire bw login --apikey proprement — il reçoit Error refreshing access token. La parade tient en une ligne : bw logout >/dev/null 2>&1 || true avant chaque login. C'est un peu sale (on jette une session active sans la valider), mais c'est la seule façon fiable.

Le token de actions/checkout. Le job finalize clone le repo avec actions/checkout@v5 with: token: ${{ secrets.GH_PAT }} pour pouvoir git push --tags ensuite. Le paramètre with: est évalué avant la résolution des env: du step, donc on ne peut pas y injecter une valeur récupérée via $GITHUB_ENV. Le pattern de sortie : retirer le token: du checkout (le clone passe sur GITHUB_TOKEN par défaut), résoudre GH_PAT via bw dans le step suivant, et reconfigurer l'URL du remote juste avant le push.

Voici le step complet — installation à la volée, login, unlock, masquage, export :

- name: Resolve SONAR_TOKEN from hub-vault
  env:
    BW_CLIENTID: ${{ secrets.BW_CLIENTID }}
    BW_CLIENTSECRET: ${{ secrets.BW_CLIENTSECRET }}
    BW_PASSWORD: ${{ secrets.BW_PASSWORD }}
  run: |
    set -e
    if ! command -v bw &>/dev/null; then
      curl -sSLo /tmp/bw.zip \
        "https://github.com/bitwarden/clients/releases/download/cli-v2024.10.0/bw-linux-2024.10.0.zip"
      unzip -qo /tmp/bw.zip -d /tmp/bw-bin
      chmod +x /tmp/bw-bin/bw
      echo "/tmp/bw-bin" >> "$GITHUB_PATH"
      export PATH="/tmp/bw-bin:$PATH"
    fi
    bw logout >/dev/null 2>&1 || true
    bw config server "https://<vault-host>" >/dev/null
    bw login --apikey
    BW_SESSION=$(bw unlock --raw --passwordenv BW_PASSWORD)
    SONAR_TOKEN=$(bw get password "SONAR_TOKEN" --session "$BW_SESSION")
    printf '\n::add-mask::%s\n' "$SONAR_TOKEN"
    echo "SONAR_TOKEN=$SONAR_TOKEN" >> "$GITHUB_ENV"

Et le remote reconfiguré juste avant le push, pour ne pas dupliquer le PAT en secret GitHub :

git remote set-url origin "https://x-access-token:${GH_PAT}@github.com/${{ github.repository }}.git"
git push origin main --tags

Un dernier détail, qui mérite mieux qu'une note de bas de page : le champ username. Vaultwarden stocke chaque item avec un username et un password. Quand j'ai créé l'item HARBOR_PASSWORD, j'ai mis admin dans le username (c'est l'utilisateur harbor). J'avais initialement hardcodé -u admin dans les docker login — exactement le mauvais réflexe : le champ existe pour ça.

HARBOR_USER=$(bw get username "HARBOR_PASSWORD" --session "$BW_SESSION")
HARBOR_PWD=$(bw get password "HARBOR_PASSWORD" --session "$BW_SESSION")
echo "$HARBOR_PWD" | docker login <registry> -u "$HARBOR_USER" --password-stdin

Le jour où on changera de compte admin harbor, on édite un item dans le coffre, et toutes les CI prennent la nouvelle valeur au prochain run. Zéro patch sur les workflows.

Le dashboard — surveillance et trois clics

La plomberie résout la rotation. Restait à savoir quand rotater, sans tenir une checklist papier en tête.

C'est ce recalcul de statut qui pilote l'alerte : dès qu'un secret bascule en due_soon (à rotater dans la semaine) ou overdue (dépassé), un event SSE part vers le dashboard et allume le badge ambré dans la sidebar.

Page Checklist OPSEC du dashboard : trois compteurs en haut — en retard, à rotater bientôt, à jour — puis les secrets groupés par criticité, un bouton Rotater par ligne.
La page OPSEC — d'où vient le badge ambré. Les secrets sont synchronisés depuis le coffre, et chaque ligne porte son bouton « Rotater ».

Quand le badge apparaît, voici le parcours.

Clic 1 — la sidebar. Cliquer « OPSEC ». La page /secrets s'ouvre, listant les secrets gérés. Trois compteurs en haut, cliquables : en retard / à rotater bientôt / à jour. Le compteur ambré filtre la liste sur celui qui m'intéresse. À cet instant, je vois immédiatement quel secret est concerné, quels services le consomment, et à quelle échéance.

Clic 2 — le bouton Rotater. Sur la ligne du secret, je clique « Rotater ». L'iframe hub-vault s'ouvre directement sur l'item concerné — le deep-link inclut le cipherId dans l'URL. Pas de navigation interne, pas de recherche par nom. L'item est là.

Clic 3 — la sauvegarde. Je saisis le master password hub-vault (le seul mot de passe que je tape encore dans la journée), je colle la nouvelle valeur, sauvegarde. La date de révision se met à jour côté coffre. Et c'est fini.

Côté machine, pendant que je ne regarde plus, le pipeline reprend tout seul. À son passage suivant, le syncer détecte la revisionDate modifiée de l'item : il met à jour la métadonnée (last_rotated_at) dans la table secret_rotations et propage la nouvelle valeur vers les Secrets k8s. Le badge, lui, ne tombe qu'au recalcul de statut d'après : last_rotated_at repasse à now, donc status = ok. Les pipelines CI qui touraient sur l'ancienne valeur continueront à fonctionner jusqu'à leur prochain run, où ils redemanderont bw get password X et obtiendront la nouvelle. Pas de redémarrage. Pour les secrets k8s consommés en runtime (postgres, OPENAI_API_KEY…), le syncer fait un kubectl create secret --dry-run | apply qui ne redémarre pas les pods ; si un rolling restart est nécessaire, je le sais d'avance — c'est dans la note hub-vault de chaque item. Je n'ai rien d'autre à faire, et surtout aucune checklist papier à parcourir.

La thèse, et ce qui reste

Quand un geste prend vingt-cinq minutes, c'est une décision. On le programme, on l'évite, on le repousse. Quand il prend vingt-cinq secondes, ce n'est plus une décision — c'est un réflexe. On le fait sans réfléchir.

Ce passage du conscient au réflexe est la vraie victoire de l'outillage. Pas le fait que c'est joli (la page /secrets est sobre, sans même un graphique). Pas le fait que c'est temps réel (le recalcul de statut est volontairement espacé). Le fait que la rotation soit moins chère à faire qu'à éviter.

L'effet de bord, qui n'était pas dans le cahier des charges initial : maintenant que c'est rapide, j'ai commencé à raccourcir les intervalles. GH_PAT passait de 365 à 90 jours hier sans douleur. OPENAI_API_KEY à 90 jours aussi — c'est celui qui paie ma facture si on me le pique. La criticité réelle d'un secret n'a pas changé, mais ma capacité à honorer une fréquence de rotation alignée sur cette criticité, oui.

Tout n'est pas automatisé, et c'est une honnêteté à poser. Quelques familles de secrets ne passent pas par la migration CI — AUTH_SECRET_KEY, JWT_SECRET_KEY, GOOGLE_CLIENT_SECRET, la clé de la GitHub App : consommées au runtime par les pods, pas par les pipelines. Les amener elles aussi sous le coffre est la prochaine étape — le pattern standard, External Secrets Operator, ne gère pas Vaultwarden self-hosté nativement, donc ce sera un petit syncer maison. Et pour les secrets qui exigent une étape côté tiers — la clé SSH du serveur, la clé PEM de la GitHub App — chaque item du coffre porte sa procédure exacte, en français, dépliable d'un clic. Pas de wiki obscur.

Et une admission : le badge est utile parce que je passe sur le dashboard tous les jours. Le jour où je ne passerai plus, il faudra qu'une notification sorte du dashboard — un message hub-zulip dans #operations, un mail hebdo, peu importe. C'est dans la roadmap. Pas urgent tant que la cadence quotidienne tient.

C'est en gros la promesse du self-host bien fait. Pas la souveraineté abstraite, pas la performance, pas le contrôle théorique. Juste : ramener à zéro la friction des opérations qu'on doit faire pour rester sain. Aujourd'hui, le geste est là. Trois clics, vingt-cinq secondes. La rotation d'un secret est devenue aussi banale qu'archiver un mail.

C'est le genre de banalité qui finit par compter.

— Damien


Voir aussi