Rustine

Gitleaks : ce que la suppression d'un secret commité laisse dans l'historique git

Supprimer un fichier .env dans le commit suivant ne supprime pas la clé. Elle reste dans l'objet Git du commit qui l'a introduite : git log -p, git cat-file et n'importe quel clone fait après la suppression la retrouvent. Sur un dépôt de test à quatre commits, grep sur l'arbre de travail renvoie zéro résultat, gitleaks dir . zéro fuite, et gitleaks git . deux fuites — une clé Stripe et un token GitHub synthétiques, dans un seul commit.

Sommaire
  1. L'arbre de travail dit zéro, l'historique dit deux
  2. Retrouver le blob, sans outil
  3. git gc ne récupère rien
  4. Réécrire, et dans quel ordre
  5. Révoquer avant tout
  6. git filter-repo , pas filter-branch
  7. Si le dernier commit est le seul concerné
  8. Ce qui reste lisible après une réécriture propre
  9. Gitleaks en pre-commit : la commande qui bloque, celle qui ne bloque rien
  10. Ce qui reste vrai

L'ordre qui règle le problème ne change pas : révoquer, réécrire, faire re-cloner. Réécrire en premier laisse la clé active pendant toute la durée de l'opération.

L'arbre de travail dit zéro, l'historique dit deux

Dépôt de test : quatre commits, 394 octets de diffs à scanner. Un package.json, un server.js Express de quatre lignes, un .env ajouté au deuxième commit, supprimé au troisième avec un .gitignore, un README.md au quatrième. Les deux valeurs du .env sont fabriquées (sk_live_4eC39HqLyjWDarjtT1zdp7dc, ghp_aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5) : elles n'ouvrent aucun accès, elles servent seulement à voir ce que les outils détectent.

code
$ grep -rn 'sk_live_' .                # arbre de travail
(rien)
$ git grep -n 'sk_live_' HEAD          # commit courant
(rien, exit 1)
$ gitleaks dir .                       # fichiers présents sur le disque
INF no leaks found
$ gitleaks git .                       # historique complet, tous les refs
WRN leaks found: 2

Les deux fuites portent le hash du commit d'ajout. Le commit de suppression n'apparaît pas : il ne contient pas la chaîne, il la retire. git diff HEAD ne peut pas aider non plus, il compare deux états dont aucun ne contient plus la clé.

Le pickaxe montre les deux bornes :

code
$ git log --all --oneline -S 'sk_live_'
bf0d952 remove env file, ignore it
21137b1 add local env file

-S compte les occurrences dans chaque diff : ressortent les commits où le nombre change, l'entrée et la sortie. Deux lignes suffisent pour savoir où regarder.

Retrouver le blob, sans outil

code
$ git rev-list --objects --all -- .env
...
e3f54aff7b4526225308f68beda47704adc54c77 .env
$ git cat-file -p e3f54aff
STRIPE_SECRET_KEY=sk_live_4eC39HqLyjWDarjtT1zdp7dc
GITHUB_TOKEN=ghp_aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5
DATABASE_URL=postgres://app:dev@localhost:5432/billing

cat-file ne vérifie ni les droits ni l'authentification : toute personne qui a le dépôt lit ce contenu. Un inventaire de composants ne dit rien de ça, il liste ce que le build a tiré, pas ce qu'un humain a collé dans un fichier ignoré — c'est le même angle mort que ce qu'un SBOM ne dit pas.

git gc ne récupère rien

Après la suppression, git gc --aggressive --prune=now : le blob est toujours là et .git est passé de 29 392 à 31 049 octets. Le gc ne supprime que les objets inaccessibles ; celui-ci est atteint par un commit ancêtre de HEAD. Il faudrait que plus aucune ref ne mène au commit d'ajout, ce qui n'arrive jamais dans une histoire linéaire.

La démonstration inverse, sur un commit jamais poussé : après git reset --hard HEAD~1, git cat-file -e <sha> lit encore le commit, et git gc --prune=now seul ne le supprime pas — le reflog le référence. Il faut vider le reflog d'abord :

bash
git reflog expire --expire=now --all
git gc --prune=now

Après ces deux commandes, git cat-file -e échoue. Tant que le reflog existe, l'objet existe.

Réécrire, et dans quel ordre

Révoquer avant tout

La doc GitHub « Removing sensitive data from a repository » commence par ça : si ce qu'on retire est un secret, la première étape est de le révoquer ou de le faire tourner, et une fois révoqué « la réécriture de l'historique peut ne pas être justifiée ». Une réécriture demande de la coordination et un force-push ; pendant ce temps la clé fonctionne encore. Pour un token de paiement, la révocation passe par le dashboard du fournisseur, pas par git — la procédure service par service est détaillée dans clé API exposée dans le JavaScript.

git filter-repo, pas filter-branch

La doc de git filter-branch (git-scm.com, dernière révision 2.44.0) ne laisse pas de place au doute : « a plethora of pitfalls », performances « abysmal », usage « not recommended », et renvoie vers git filter-repo.

bash
git clone --mirror git@github.com:org/app.git app.git
cd app.git
git filter-repo --path .env --invert-paths
git remote add origin git@github.com:org/app.git
git push --force --all
git push --force --tags

filter-repo retire le remote origin à la fin de l'opération (« Removing 'origin' remote ») pour éviter un push accidentel : il faut le remettre. Il écrit aussi son journal dans .git/filter-repo/, et commit-map donne la correspondance entre anciens et nouveaux hash — utile pour retrouver un commit après coup.

Mesures sur le dépôt de test : 4 commits avant, 3 après. Le commit qui ne faisait qu'ajouter le .env devient vide et disparaît ; celui qui supprimait le fichier reste, réduit à l'ajout du .gitignore. Les commits postérieurs à l'ajout changent de hash, celui d'avant garde le sien, et l'ancien HEAD n'est plus lisible (git cat-file -e échoue). git log -S ne renvoie plus rien, gitleaks git . renvoie 0, et un clone neuf ne contient plus la chaîne. git fsck --unreachable est vide : le nettoyage est complet.

Sur un dépôt plus proche du réel — 200 commits, .env introduit au 87e, supprimé au 120e — la réécriture a pris 0,21 s et .git est passé de 189,8 à 121,3 Ko. Le coût n'est pas la machine, il est humain : chaque clone existant doit être jeté.

Si le dernier commit est le seul concerné

git commit --amend suffit, à condition que rien n'ait été poussé. Mesure : .env ajouté puis retiré dans le commit amendé, le SHA du parent ne bouge pas, gitleaks git . renvoie 0. Sauf que le blob abandonné traîne encore : git fsck --unreachable liste le commit, l'arbre et le blob. Même nettoyage que plus haut, reflog expire puis gc --prune=now.

Ce qui reste lisible après une réécriture propre

Un git clone --mirror fait avant la réécriture contient toujours le blob, et gitleaks l'y trouve. C'est exactement ce que décrit la doc GitHub : un force-push ne touche ni les clones, ni les forks, ni les vues en cache accessibles par SHA, ni les pull requests qui pointent les commits. Et un collègue qui fait git pull puis git push depuis son vieux clone réintroduit la clé — « high risk of recontamination » dans les termes de la doc. Le support GitHub ne purge les vues en cache que si le risque ne peut pas être couvert par une rotation.

Séquence : révoquer, réécrire, prévenir l'équipe (jeter les clones, pas git pull), fermer ou merger les PR ouvertes, désactiver la protection de branche, force-push, puis vérifier sur un clone neuf.

Gitleaks en pre-commit : la commande qui bloque, celle qui ne bloque rien

gitleaks git . sur les 200 commits du banc : 0,53 s. Sur les 4 commits du petit dépôt : 199 ms pour 394 octets. Le scan complet n'est pas cher.

En pre-commit, la commande qui marche est --staged :

code
$ gitleaks git --staged --exit-code 1
INF scanned ~51 bytes (51 bytes) in 187ms
WRN leaks found: 1                      # exit 1
$ gitleaks git --pre-commit --exit-code 1
INF scanned ~0 bytes (0) in 186ms
INF no leaks found                      # exit 0

Le second appel est le piège : --pre-commit scanne le git diff (« scan using git diff » dans l'aide de la 8.28.0). Sur un fichier entièrement nouveau, il n'y a pas de diff à comparer — 0 octet scanné, sortie 0, hook qui ne bloque jamais. Un hook recopié d'un billet de 2023 sans le tester produit exactement ça : une fausse sécurité qui ne se voit pas. Même mécanique que Dependabot muet pendant huit jours ou qu'un npm audit à zéro : un contrôle qui répond « rien » n'est pas un contrôle qui a regardé.

Hook vérifié sur gitleaks 8.28.0 :

bash
#!/bin/sh
# .git/hooks/pre-commit
exec gitleaks git --staged --redact --no-banner --exit-code 1

En CI, le même scan sur tout l'historique, binaire épinglé :

yaml
- name: gitleaks
  run: |
    curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.28.0/gitleaks_8.28.0_linux_x64.tar.gz | tar xz
    ./gitleaks git . --redact --exit-code 1

Les faux positifs se neutralisent dans .gitleaksignore, avec l'empreinte exacte du finding au format commit:fichier:règle:ligne :

code
21137b1395a80a92a68b73afbb28961563795fda:.env:stripe-access-token:1

À réserver aux valeurs de test : ignorer une vraie clé revient à ne pas scanner.

Ce qui reste vrai

Un secret commité est un secret publié, y compris dans un dépôt privé : clones, sauvegardes, postes de dev et artefacts de CI en gardent des copies. Réécrire l'historique nettoie le dépôt central, pas ces copies. La révocation est la seule mesure qui met la clé hors service ; le reste est de l'hygiène. Un gitleaks git . en pre-commit plus le même scan en CI couvrent l'essentiel — rustine refait ce contrôle à chaque déploiement, ce qui n'excuse pas de laisser entrer la clé en amont.

Ton app est-elle touchée ? Regarde en une minute.Scanner mon site