VT Security blog

Subiste el .env a GitHub: qué pasa en los primeros minutos

Borrar el archivo no borra la key. Qué hacer, en qué orden, y por qué rotar va antes que limpiar el historial.

En este artículo
  1. El archivo que borraste
  2. Quién mira un push público
  3. Rotar antes que limpiar
  4. Sacarlo del historial
  5. Que no vuelva a pasar
  6. El criterio

Pasa así: armás un proyecto chico, ponés la API key en un .env, te olvidás del .gitignore y hacés git push. Diez minutos después te das cuenta, borrás el archivo, commit, push. En la pestaña de GitHub el .env ya no está.

Y sin embargo la key sigue ahí. No es un bug de GitHub ni mala suerte: es cómo funciona Git. Este artículo es sobre esos minutos y sobre qué hacer, en orden.

El archivo que borraste #

Git no guarda archivos sueltos: guarda una foto del proyecto entero en cada commit. Cuando borrás el .env, creás una foto nueva sin el archivo. Las anteriores siguen existiendo, y la pestaña de GitHub te muestra solo la última, HEAD.

Fijate en qué fotos queda el .env. Con las flechas podés volver al primer commit, y el último paso muestra qué se lleva alguien que clona el repo.

tu repo en GitHubla máquina de otra personatodavía nadie lo clonóa1f3c09primer commitREADME.mdapp.jsprecio.js.env7c2e4b1conecto la APIREADME.mdapp.jsprecio.js.env9b41d7eagrego /precioREADME.mdapp.jsprecio.js.enve05d2a8borro el .envREADME.mdapp.jsprecio.js.enva1f3c09primer commitREADME.mdapp.jsprecio.js.env7c2e4b1conecto la APIREADME.mdapp.jsprecio.js.env9b41d7eagrego /precioREADME.mdapp.jsprecio.js.enve05d2a8borro el .envREADME.mdapp.jsprecio.js.envHEADtu repo en GitHubotra máquinatodavía nadie lo clonóa1f3c09primer commitREADME.mdapp.jsprecio.js.env7c2e4b1conecto la APIREADME.mdapp.jsprecio.js.env9b41d7eagrego /precioREADME.mdapp.jsprecio.js.enve05d2a8borro el .envREADME.mdapp.jsprecio.js.enva1f3c09primer commitREADME.mdapp.jsprecio.js.env7c2e4b1conecto la APIREADME.mdapp.jsprecio.js.env9b41d7eagrego /precioREADME.mdapp.jsprecio.js.enve05d2a8borro el .envREADME.mdapp.jsprecio.js.envHEAD

4 de 5Borrás el .env. La foto nueva, HEAD, no lo tiene. Las dos anteriores sí.

Repo de ejemplo. Cada hoja es la foto que guarda un commit.

Un clone trae todas las fotos, no solo la última. Cualquiera puede buscar el archivo en el historial y leerlo:

terminal
$ git log --all --oneline -- .env
e05d2a8 borro el .env
7c2e4b1 conecto la API
$ git show 7c2e4b1:.env
OPENAI_API_KEY=sk-proj-7Hq2…

Quién mira un push público #

Un repo público no es público solo para personas. Hay scanners que leen el feed de eventos públicos de GitHub y buscan cadenas con forma conocida: AKIA en las keys de AWS, ghp_ en los tokens de GitHub, sk- en varios proveedores de IA. No necesitan entrar a tu repo: les llega el diff.

La pregunta útil no es si alguien la vio. Es cuánto tiempo pasó entre el push y el momento en que vos te diste cuenta: esa es la ventana de exposición. Mové tu marca y mirá qué alcanzó a pasar.

051015202530 minlos scanners la leencada punto es un uso de la keypushte das cuenta0102030 min3 scanners la leencada punto es un uso de la keypushte das cuenta
12 min

Cuando te diste cuenta, alguien la venía usando hace 8 min. 2 usos antes de que reaccionaras.

Tiempos ilustrativos. Varían según el tipo de secreto.

Del otro lado, GitHub tiene secret scanning: para algunos proveedores le avisa al emisor de la key, que puede revocarla sola. Para otros no pasa nada. Por eso lo que importa es qué podía hacer esa key.

SecretoFormaAvisa GitHubQué hacer primero
Access key de AWSAKIA…Desactivarla en IAM y mirar CloudTrail
Token de GitHubghp_…Revocarlo y generar otro con scopes mínimos
Key de IAsk-…dependeRevocarla y poner límite de gasto
Contraseña de la baseningunanoCambiarla y revisar conexiones

Rotar antes que limpiar #

El instinto es reescribir el historial para que no quede rastro. Para mí ese es el error más común: se siente como arreglarlo, y no lo es. Mientras reescribís, la key sigue siendo válida en cada copia que ya existe, y ninguna reescritura llega hasta ahí.

Probá los dos órdenes. La key vive en el proveedor; las líneas rojas son las copias que todavía pueden usarla. Rotar la mata en todas a la vez.

proveedor de la APIsk-proj-7Hq2… válidask-proj-Rt9w… nuevauso revisado desde el pushtu repo en GitHub.env · key válida.env · key muertahistorial limpioun fork.env · key válida.env · key muertahistorial limpioclone de un bot.env · key válida.env · key muertahistorial limpioproveedor de la APIsk-proj-7Hq2… válidask-proj-Rt9w… nuevauso revisado desde el pushtu repokey válidakey muertalimpioun forkkey válidakey muertalimpioun botkey válidakey muertalimpio

copias con la key válida: 3. minutos con la key activa: 0.

1 de 4Te das cuenta. La key es válida en las tres copias.

Los minutos son un ejemplo: reescribir, forzar el push y avisarle al equipo lleva su rato.
  1. Revocar o rotar la key en el proveedor. Antes que cualquier otra cosa.
  2. Revisar el uso desde el momento del push: facturación, CloudTrail, el panel que tenga el proveedor.
  3. Sacar el archivo del historial, recién ahora.
  4. Frenar el próximo push antes de que salga de tu máquina.

Sacarlo del historial #

Para reescribir uso git filter-repo, que reemplazó a filter-branch. Cambia el hash de cada commit desde el que tocó el archivo, así que cualquiera que trabaje en el repo va a tener que volver a clonar.

Dos cosas te van a frenar la primera vez. Sobre un repo en el que venís trabajando, filter-repo no arranca:

código
Aborting: Refusing to destructively overwrite repo history since
this does not look like a fresh clone.

Está pensado para correr sobre un clone recién hecho. Si lo hacés sobre el tuyo, va --force. Y cuando termina te borra el remoto origin a propósito, para que no pushees de memoria; si no lo volvés a agregar, el push falla con No configured push destination.

terminal
# primero: ¿en qué commits está?
git log --all --full-history --oneline -- .env
 
# después de rotar la key, recién ahí
git filter-repo --invert-paths --path .env --force
git remote add origin git@github.com:vos/tu-repo.git
git push --force --all

Los hashes cambian todos, incluso los de commits anteriores al que tocó el archivo.

Que no vuelva a pasar #

Lo más barato es que el commit no salga. Un hook de pre-commit con gitleaks mira lo que estás por commitear y frena si encuentra algo con forma de secreto.

.pre-commit-config.yaml
# frena el commit si encuentra algo con forma de secreto
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks

No hace falta pasarle --redact: el hook ya corre gitleaks git --pre-commit --redact --staged, así que lo que encuentra sale tapado y no te deja el secreto en la salida de la terminal.

Y agregá .env al .gitignore antes del primer commit, con un .env.example sin valores para que se entienda qué variables hacen falta.

El criterio #

Si una key tocó un repo público, aunque sea un minuto, tratala como comprometida. No hay forma de probar que nadie la leyó, y rotarla cuesta menos que averiguarlo.

Valentín Torassa. Hago videos de ciberseguridad en YouTube. Si encontraste un error, avisame en los comentarios y lo corrijo con una nota al pie.

Comentarios

Pasan por moderación antes de aparecer.

Lo próximo llega también por mail. Cada dos semanas, en Apuntes.