Facevo una revisione generale del mio progetto, alla ricerca di cose dimenticate. Ne ho trovata una che non avrei voluto: un file di configurazione per il deploy, con dentro la chiave che firma i token di login, la password dell'amministratore e la password per l'invio delle email. In chiaro, nella history di git, su tre branch.
Com'è potuto succedere
Il file era nel .gitignore. Il punto è che ci era finito dopo essere stato committato una prima volta, e .gitignore ignora solo i file non ancora tracciati: un file già tracciato continua a esserlo, modifica dopo modifica, anche se il suo nome compare nella lista.
Il repository era privato, ma privato non vuol dire sicuro: lo vedono i collaboratori, i servizi di CI, i backup e ogni computer su cui è stato clonato.
Rimuoverlo da tutta la history
Cancellare il file con un nuovo commit non serve: resta in tutti i commit precedenti. Bisogna riscrivere la history di ogni branch. Ho usato git filter-repo su un clone mirror, poi ho forzato il push e verificato che nessun commit lo contenesse più:
# 1. Stop tracking the file (it stays on disk)
git rm --cached .env.deploy
git commit -m "Stop tracking .env.deploy"
# 2. Rewrite the history of EVERY branch on a mirror clone
git clone --mirror git@github.com:me/my-repo.git
cd my-repo.git
git filter-repo --path .env.deploy --invert-paths
git push --force --mirror
# 3. Verify: no commit on any branch may still contain it
git log --all --oneline -- .env.deploy
Dopo una riscrittura del genere, chiunque abbia una copia locale deve riallinearla al nuovo remoto: i vecchi commit non vanno più ripubblicati.
Perché non basta
Qui sta la lezione più importante. Anche dopo la riscrittura, i vecchi commit possono restare raggiungibili: GitHub, per esempio, mantiene dei riferimenti interni alle pull request che un force push non può cancellare. E non si sa chi abbia già scaricato il repository in passato.
Rimuovere un segreto dalla history serve a non peggiorare la situazione; l'unica cosa che la risolve è cambiarlo. In pratica:
- Nuova chiave di firma dei token: invalida tutte le sessioni esistenti, quindi tutti dovranno rifare il login. È il prezzo da pagare.
- Nuova password dell'amministratore.
- Revoca della password per l'app di posta e generazione di una nuova.
- Aggiornamento delle variabili su ogni ambiente in cui girano.
La seconda volta, stesso schema
Qualche settimana dopo ho trovato lo stesso problema in un'altra forma: uno script di installazione del server, comodissimo, che scriveva le credenziali direttamente nel file di configurazione. È lo schema ricorrente: lo script di comodità che contiene i segreti per non dover configurare niente. L'ho riscritto perché generi le chiavi al momento e chieda le password a chi lo esegue:
# Before: secrets written into a script tracked by git
# JWT_SECRET=hardcoded-value...
# After: generated or asked at runtime, never committed
JWT_SECRET="$(openssl rand -base64 48)"
read -rsp "SMTP password: " SMTP_PASS; echo
umask 077 # the .env file is readable by its owner only
printf 'JWT_SECRET=%s\nSMTP_PASS=%s\n' "$JWT_SECRET" "$SMTP_PASS" > .env
Le abitudini che evitano che succeda di nuovo
- Un
.env.examplecommittato, con i nomi delle variabili e nessun valore; il.envvero non entra mai nel repository. - Uno scanner di segreti prima di ogni commit, come gitleaks in un hook di pre-commit: blocca il commit prima che il danno sia fatto.
- La protezione dei push offerta da GitHub e GitLab, che rifiuta i push contenenti segreti riconoscibili.
- Mai segreti negli script: generarli a runtime o leggerli dall'ambiente.
- Un controllo periodico della history: un segreto trovato da te è un incidente piccolo, uno trovato da altri no.
In sintesi
Un segreto in un repository va trattato come compromesso nel momento stesso in cui lo trovi. Riscrivere la history è necessario ma non sufficiente: la vera correzione è cambiare ogni credenziale esposta. Poi conviene rendere il problema impossibile da ripetere, con scanner e regole chiare. Ho raccolto altre pratiche di sicurezza in questa guida.