Módulo 4: Secrets Environments And Identity
2. Por qué una credencial nunca vive en el repositorio
Descripción
Esta lección nombra, con precisión, el antipatrón exacto que cita la auditoría de mercado: una clave de acceso de AWS de vida larga (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY de un usuario IAM tradicional), escrita en texto plano dentro de un archivo que Git rastrea. Vas a ver, con un experimento real —no una afirmación en prosa—, por qué la solución no es "borrar el archivo": una vez que una credencial entra a un commit, vive en el historial de Git, no en el estado actual del repositorio, y el historial no se olvida de nada por accidente.
Conexión con el módulo
La lección 1 ya señaló dónde vive hoy la forma de este antipatrón: el bloque env: de ci.yml, con AWS_ACCESS_KEY_ID: test escrito directamente en el YAML. Esta lección explica exactamente por qué eso es un problema —incluso con una credencial inofensiva— y qué lo distingue de un problema "que se arregla con un commit más". La lección 3 construye la solución de GitHub para este caso específico: Secrets, gestionados fuera del YAML. Esta lección es el porqué; la lección 3 es el cómo.
Analogía: la libreta de un club de secretos, donde nadie arranca páginas
Imagina un club que lleva una libreta física donde cada miembro anota, con fecha, cada decisión que toma. Es la regla del club: nada se olvida, nada se reescribe, cada página queda archivada para siempre, incluso las que después se corrigen. Un día, alguien anota por error la combinación de la caja fuerte del club directamente en una página. Al darse cuenta, arranca esa página de la libreta actual —pero la libreta tiene una regla más: cada página arrancada se archiva en una carpeta aparte, indexada, consultable por cualquiera que sepa dónde buscar. "Arrancar la página" no borró la combinación; solo la movió a un archivo distinto, igual de accesible. La única forma real de proteger la caja fuerte, a esas alturas, es cambiar la combinación —no reescribir la libreta.
Git funciona así. Un commit no es una "versión que reemplaza a la anterior" — es un objeto nuevo, con su propio identificador, que se agrega a una cadena que no se reescribe hacia atrás por accidente. "Eliminar" un archivo en un commit nuevo no borra el contenido de los commits viejos donde ese archivo existía: solo agrega, a la cadena, un commit más que dice "a partir de aquí, este archivo ya no está". Los commits anteriores —con el archivo completo, credencial incluida— siguen ahí, tan accesibles como cualquier otro punto del historial.
Ejemplo trabajado: recuperar una credencial "eliminada"
Vas a reproducir, en un repositorio desechable —no en andes-cargo-infra/—, exactamente el error que la lección 1 te pidió que evitaras. El objetivo es ver, con tus propios ojos, que "eliminar el archivo" no elimina el problema.
Crea un repositorio nuevo, en cualquier carpeta fuera de tu proyecto real:
mkdir git-secret-history-demo && cd git-secret-history-demo
git init -b main
Crea un archivo con credenciales de ejemplo —nunca credenciales reales, ni siquiera en un experimento— y commitéalo, exactamente como haría alguien que todavía no leyó esta lección:
cat > config.env <<'EOF'
DATABASE_URL=postgres://user:pass@localhost/db
AWS_ACCESS_KEY_ID=AKIAEXAMPLEDEMOKEY01
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIdemoDEMOsecretKEYexample
EOF
git add config.env
git commit -m "Add config.env with database and AWS credentials"
Anota el hash de este commit —lo vas a necesitar en el siguiente paso—:
git rev-parse HEAD
Qué esperar (representativo en el hash — el tuyo va a ser distinto, cada commit genera un identificador único basado en su contenido y momento exacto):
c0551d84d7d9f6e0e65b70cce9d86a328528cd43
Ahora, reacciona como reaccionaría alguien que se da cuenta del error: elimina el archivo y commitea la corrección.
git rm config.env
git commit -m "Remove config.env (credentials were exposed)"
Confirma que el archivo ya no existe en el directorio de trabajo:
ls
git log --oneline
Qué esperar (literal en la estructura, representativo en los hashes cortos):
c45646e Remove config.env (credentials were exposed)
c0551d8 Add config.env with database and AWS credentials
ls no muestra ningún archivo — desde la perspectiva del directorio de trabajo actual, config.env ya no existe. Esto es exactamente el punto donde alguien sin esta lección respiraría aliviado, pensando que el problema está resuelto.
La recuperación: el momento que importa
Ahora, con el hash del primer commit que anotaste arriba, pide a Git que te muestre ese archivo tal como existía en ese commit específico, sin revertir nada, sin deshacer el commit de eliminación:
git show c0551d8:config.env
Qué esperar (literal, con tu propio hash del primer commit):
DATABASE_URL=postgres://user:pass@localhost/db
AWS_ACCESS_KEY_ID=AKIAEXAMPLEDEMOKEY01
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIdemoDEMOsecretKEYexample
Ahí está — completa, sin ningún esfuerzo especial, sin herramientas de recuperación forense. git show <commit>:<archivo> es un comando de lectura estándar, el mismo tipo de comando que usarías para revisar cualquier versión histórica de cualquier archivo. La credencial nunca dejó de existir en el repositorio: el commit de "eliminación" solo agregó una página nueva a la libreta, sin arrancar ninguna de las anteriores.
Este mismo repositorio, si lo hubieras subido a GitHub antes del segundo commit —o incluso después—, expone esa credencial a cualquiera con acceso de lectura al repositorio, para siempre, hasta que alguien reescriba activamente el historial (una operación destructiva, arriesgada, que reescribe hashes de commits posteriores) o —la única respuesta correcta y rápida— revoque la credencial expuesta en el proveedor donde vive (en este caso, AWS IAM), volviéndola inútil sin importar cuántas copias de ella sigan circulando.
Por qué "revocar", no "reescribir el historial", es la respuesta correcta
Reescribir el historial de Git (con herramientas como git filter-repo o BFG Repo-Cleaner) es técnicamente posible, pero tiene un problema que ninguna de las dos resuelve: si el repositorio ya fue clonado, forkeado, o el commit expuesto ya fue indexado por algún bot de escaneo automático (GitHub mismo corre escaneo de secretos en repositorios públicos, y muchos privados), la credencial ya escapó del repositorio antes de que pudieras reescribirlo. Reescribir el historial limpia tu copia local y la del remoto principal — no las copias que ya existen en otro lado.
La única acción que neutraliza el riesgo real, sin importar cuántas copias existan, es revocar la credencial en el proveedor: en AWS, desactivar o eliminar la clave de acceso expuesta desde IAM. Una vez revocada, esa clave deja de funcionar en cualquier copia del historial, en cualquier fork, en cualquier bot que la haya indexado — el archivo config.env puede seguir viviendo en mil commits viejos de mil clones distintos, y no importa, porque la credencial que contiene ya no abre ninguna puerta.
Esta es la razón exacta detrás de una regla que quizás ya conoces por instinto: nunca reutilices una credencial real en un experimento, ni siquiera "solo para probar". El ejemplo de arriba usó AKIAEXAMPLEDEMOKEY01 — un valor de ejemplo, con el prefijo EXAMPLE que AWS reserva explícitamente para documentación, nunca una clave activa. Si en algún punto copiaste una credencial real a un archivo de prueba "solo para ver cómo funciona git rm", ya tienes una tarea pendiente: revócala ahora, no después de terminar esta lección.
De vuelta a andes-cargo-infra/: por qué el caso de esta guía es distinto (y por qué igual importa)
El ci.yml de Andes Cargo tiene AWS_ACCESS_KEY_ID: test escrito directamente en el archivo, ya commiteado desde el Módulo 3. Siguiendo el razonamiento de arriba, ¿deberías tratarlo como una fuga real? No —y la razón es específica, no una excepción genérica: test no es una credencial de AWS, es un valor que LocalStack reconoce como "no valides nada, esto es un laboratorio local". No existe ninguna cuenta AWS real donde esa cadena de texto abra una puerta. Revocar "la clave test" no tiene sentido, porque nunca fue una clave.
Lo que sí importa —y por qué esta lección no es un ejercicio aislado sin conexión al resto de la guía— es la forma. La lección 7 de este módulo va a migrar exactamente ese bloque de ci.yml para que lea sus valores de un Secret en vez de tenerlos escritos ahí, no porque test/test sea peligroso, sino porque practicar el patrón correcto con una credencial inofensiva es la forma de que el patrón esté instalado en tu memoria muscular el día que trabajes con una credencial que sí importa.
Errores comunes
Creer que .gitignore protege retroactivamente (el error central de esta lección). Qué pasa: alguien agrega un archivo a .gitignore después de haberlo commiteado, y asume que eso lo saca del historial. Por qué pasa: .gitignore sí evita que Git rastree un archivo nuevo, y esa fue exactamente la técnica correcta que usó el Módulo 2, lección 7, con .secrets — pero solo funciona si se aplica antes del primer commit que incluye el archivo. Cómo detectarlo: si un archivo aparece en git log -p o en git show <commit>:<archivo> a pesar de estar listado en .gitignore hoy. Cómo corregirlo: .gitignore es prevención, no cura. Si el archivo ya entró a un commit, agregarlo a .gitignore detiene commits futuros con ese contenido, pero no borra los commits pasados — para eso hace falta revocar la credencial (si es real) y, si además quieres limpiar el historial, una herramienta de reescritura como las nombradas arriba.
Confundir "commit sin push" con "commit seguro" (de alcance). Qué pasa: alguien razona que, como todavía no hizo git push, el commit con la credencial "solo existe en mi máquina" y no hay riesgo real. Cómo detectarlo: si tu plan para "arreglar" un commit con una credencial es simplemente no subirlo, en vez de corregirlo antes de que exista la posibilidad de subirlo. Cómo corregirlo: es cierto que un commit sin push no ha llegado a ningún remoto todavía — pero el riesgo de un push accidental (una rama que se sube sin querer, un git push --all, un colaborador que clona tu máquina) es real y common. La práctica correcta es la que usó el Módulo 2, lección 7: gitignorar antes de que el archivo con la credencial exista, no confiar en recordar no subirlo.
Ejercicios
Ejercicio 1 — Reproduce el experimento con un segundo archivo. Repite el ejercicio de esta lección, pero con un archivo nuevo, api-token.txt, que contenga un único valor de ejemplo. Después de "eliminarlo" en un segundo commit, confirma con git show que sigue siendo recuperable. Anota los dos hashes que necesitas.
Ver solución
echo "API_TOKEN=demo-token-never-use-this-in-production" > api-token.txt
git add api-token.txt
git commit -m "Add api-token.txt"
git rev-parse HEAD # anota este hash, por ejemplo abc1234
git rm api-token.txt
git commit -m "Remove api-token.txt"
git show abc1234:api-token.txt
El último comando debería devolver API_TOKEN=demo-token-never-use-this-in-production, exactamente igual que el ejercicio de la lección con config.env. El patrón es idéntico sin importar el nombre del archivo ni el tipo de credencial: cualquier contenido que entre a un commit es recuperable desde ese commit específico, para siempre, sin importar cuántos commits posteriores lo "eliminen".
Ejercicio 2 — Explica por qué test/test en ci.yml no exige revocación. Un colega, después de esta lección, entra en pánico al ver AWS_ACCESS_KEY_ID: test en el ci.yml de Andes Cargo y pregunta si hay que revocar algo en AWS ahora mismo. Respóndele en dos o tres frases.
Ver solución
Una respuesta completa suena, más o menos, así: "No hay nada que revocar, porque test nunca fue una credencial de una cuenta AWS real — es un valor especial que LocalStack acepta sin validar contra ningún servicio real de AWS, exactamente como lo usamos desde terraform-and-iac-guide. El riesgo que esta lección explica aplica a credenciales que sí abren una puerta real; test/test no abre ninguna. Lo que sí vamos a corregir, en la lección 7, es la forma en que está escrita —directamente en el YAML—, porque ese es el hábito que queremos tener instalado antes de trabajar con una credencial que sí importe."
Ejercicio 3 — Distingue prevención de cura. Clasifica cada una de estas tres acciones como "prevención" (evita el problema antes de que ocurra) o "cura" (responde después de que la credencial ya está expuesta): (a) agregar .secrets a .gitignore antes de crear el archivo; (b) revocar una clave de AWS que apareció en un commit ya subido a un repositorio público; (c) reescribir el historial de Git con git filter-repo para eliminar un commit con una credencial.
Ver solución
(a) Prevención — exactamente el patrón del Módulo 2, lección 7: el archivo nunca llega a entrar a un commit. (b) Cura, y la única que realmente neutraliza el riesgo — la credencial deja de funcionar sin importar cuántas copias del historial existan en el mundo. (c) Cura parcial, con valor limitado — limpia tu copia del historial y la del remoto principal, pero no alcanza copias ya clonadas, forkeadas, o indexadas por un bot de escaneo; nunca sustituye a la revocación, en el mejor de los casos la complementa.
Resumen y siguiente paso
En esta lección viste, con un experimento real y reproducible, por qué una credencial que entra a un commit de Git no se elimina cuando se elimina el archivo en un commit posterior: el historial es aditivo, no destructivo, y git show <commit>:<archivo> recupera cualquier contenido histórico sin ninguna herramienta especial. También distinguiste el caso real de Andes Cargo (test/test, inofensivo en su contenido) del caso general que esta lección enseña (la forma del antipatrón, que sí importa practicar bien), y viste por qué revocar la credencial —no reescribir el historial— es la única respuesta que neutraliza el riesgo sin importar cuántas copias existan.
Antes de avanzar deberías poder: explicar, sin usar la palabra "borrar", qué le pasa realmente a un archivo eliminado en un commit posterior de Git; recuperar el contenido de un archivo desde un commit específico con git show; y explicar por qué revocar una credencial expuesta es la respuesta correcta, incluso después de reescribir el historial.
La lección 3 construye la solución de GitHub para que esto no vuelva a pasar: Secrets, un mecanismo gestionado completamente fuera del archivo YAML, que nunca entra al historial de Git porque nunca vive en un archivo que Git rastree.
Recursos
- Git Docs —
git show— documentación oficial del comando usado en esta lección para recuperar contenido histórico. - GitHub Docs — About secret scanning — el sistema automático de GitHub que detecta credenciales expuestas en commits, la razón adicional por la que "nadie se dio cuenta" no es una estrategia confiable.
- AWS Docs — Identifying unintended access to your AWS resources — documentación oficial de AWS sobre gestión y rotación de claves de acceso IAM, la base del "revocar, no reescribir" de esta lección.
- Módulo 2, lección 7 de esta guía (
07-hands-on-passing-secrets-to-act.md) — el patrón correcto de prevención (gitignoreantes del primer commit), ya aplicado a.secrets.