La lección anterior dejó a gestor-tareas con los ficheros correctos dentro y bien tratados. Queda el asunto que anunciamos al cerrar el módulo 7 y que hemos ido aplazando deliberadamente: hay un config.js con la contraseña de la base de datos en el historial desde hace ocho meses, y el repositorio lleva cuatro meses siendo público para que Diego pueda colaborar desde su fork.

Esta lección cierra tres promesas del curso: la de la lección 04-03, donde vimos credential.helper store y advertimos de que guarda las credenciales en texto plano remitiendo aquí; la de la lección 08-03, donde dijimos que git rm --cached no borra un secreto del historial; y la de la lección 06-01, donde los hooks quedaron como herramienta de comodidad, no de seguridad.

El enfoque es preventivo y de remediación. Se organiza en cuatro bloques: por qué un repositorio es el peor sitio posible para un secreto, cómo detectar una fuga antes y después de que ocurra, qué hacer exactamente si ya ha pasado (y el orden de los pasos importa muchísimo), y cómo mantener la confianza en la autenticación y en la autoría.

Aviso previo. Este material es formativo y general. En entornos regulados —datos personales, sanitarios, financieros, sector público— una filtración de credenciales puede tener obligaciones legales de notificación con plazos estrictos. Antes de actuar, avisa a tu responsable de seguridad o de cumplimiento normativo. Reescribir un historial también puede tener implicaciones de auditoría. Lo que sigue son buenas prácticas técnicas, no asesoramiento legal.

Contenido

  1. Por qué un repositorio es un lugar pésimo para un secreto
  2. Qué cuenta como secreto
  3. Gestión correcta: variables de entorno y gestores de secretos
  4. Detección: hooks de cliente y análisis en el CI
  5. Si ya se filtró: el procedimiento por orden
  6. Paso 1 — Rotar la credencial
  7. Paso 2 — Reescribir el historial con git filter-repo
  8. Paso 3 — Coordinar con el equipo
  9. Paso 4 — Las copias que quedan en las plataformas
  10. Por qué git rm no sirve
  11. Autenticación: claves SSH, ssh-agent y gestores de credenciales
  12. Firmar commits y etiquetas
  13. Higiene: permisos, ramas protegidas y --force
  14. Antes de hacer público un repositorio

  1. Por qué un repositorio es un lugar pésimo para un secreto

Un repositorio de Git tiene cuatro propiedades que, combinadas, lo convierten en el peor contenedor imaginable para una credencial:

1. Se clona entero. Recuerda de la lección 01-01 que Git es distribuido: git clone no descarga la punta, descarga todo el historial. Cada persona que haya clonado alguna vez tiene una copia completa de tu secreto en su disco.

2. Es inmutable por diseño. En la lección 01-04 vimos que un commit es inmutable: su SHA es el hash de su contenido. Un blob con la contraseña sigue existiendo mientras algo lo referencie, y borrarlo requiere reescribir todos los commits posteriores, es decir, romper la regla de oro de la lección 05-01.

3. Se replica sin control. Forks (Diego tiene uno), réplicas, copias de seguridad, cachés de CI, imágenes de contenedor que hicieron git clone, entornos de desarrollo de gente que ya no está en la empresa.

4. Es muy fácil de buscar. No hay que leer commit a commit. Un solo comando lo encuentra todo:

git log --all -p -S 'contrasena' --oneline
git rev-list --all | xargs git grep -n 'clave' 2>/dev/null

En un repositorio público, existen herramientas y servicios que escanean continuamente los repositorios recién publicados buscando patrones de credenciales. El tiempo medio entre publicar una clave válida y el primer intento de uso se mide en minutos, no en días.

flowchart TD
    A["Commit con el secreto"] --> B["push a git.ejemplo.es"]
    B --> C["Clon de Ana"]
    B --> D["Clon de Bruno"]
    B --> E["Clon de Carla"]
    B --> F["Fork de Diego"]
    B --> G["Caché del CI"]
    B --> H["Copia de seguridad"]
    F --> I["Clones del fork"]
    B --> J["Escáneres automáticos<br/>si es público"]

Cada nodo de ese grafo es una copia sobre la que no tienes ningún control. De ahí sale la conclusión que gobierna toda la lección: la única acción que de verdad neutraliza un secreto filtrado es invalidarlo. Todo lo demás —reescribir, borrar, forzar— es limpieza posterior, importante pero secundaria.

  1. Qué cuenta como secreto

La lista es más larga de lo que la gente supone. Todo lo siguiente nunca debe estar en un repositorio:

Categoría Ejemplos
Contraseñas De bases de datos, de servicios internos, de cuentas de servicio, de correo
Claves de API y tokens De cualquier proveedor, tokens de acceso personal (PAT), tokens de sesión, claves de webhook
Claves criptográficas Claves privadas SSH (id_rsa, id_ed25519), certificados con clave privada (.pem, .p12, .pfx), claves de firma
Cadenas de conexión postgres://usuario:clave@host/bd, mongodb+srv://..., cualquier URL con credenciales embebidas
Secretos de aplicación Claves de firma de sesiones y de JWT, claves de cifrado en reposo, semillas de generación
Credenciales de infraestructura Claves de acceso a proveedores de nube, ficheros de credenciales de despliegue, tokens de registro de contenedores
Datos personales Volcados de base de datos con datos reales de usuarios, ficheros de exportación, capturas con datos identificables
Configuración sensible URLs de servicios internos no publicados, rutas de red, direcciones de infraestructura privada, nombres de host internos

Dos categorías que la gente olvida sistemáticamente:

  • Los datos personales en volcados de prueba. Un dump.sql "de pruebas" con correos y teléfonos reales de usuarios es una filtración de datos personales, con las consecuencias legales que eso conlleva.
  • La información de infraestructura. No es una credencial, pero un atacante que sabe cómo se llaman tus servidores internos y qué puertos usan tiene medio trabajo hecho.

El criterio práctico:

Si un valor caduca, se rota o se puede revocar, es un secreto. Si al verlo un extraño podría hacer algo que tú no querrías, es un secreto. En caso de duda, es un secreto.

  1. Gestión correcta: variables de entorno y gestores de secretos

La regla estructural es simple:

La configuración que cambia entre entornos va fuera del código. Los secretos van fuera del repositorio, siempre.

Nivel 1: variables de entorno y .env ignorado

Es lo que vimos al final de la lección 08-03, y para un proyecto pequeño es suficiente.

// app.js — el código lee del entorno, nunca contiene el valor
const config = {
  baseDatosUrl: process.env.BASE_DATOS_URL,
  claveCorreo:  process.env.API_CORREO_CLAVE,
  sesionSecreto: process.env.SESION_SECRETO,
  puerto: process.env.PUERTO || 3000,
};

// Fallar pronto y con claridad si falta algo
for (const [clave, valor] of Object.entries(config)) {
  if (valor === undefined) {
    throw new Error(`Falta la variable de entorno para "${clave}". Copia .env.ejemplo a .env.`);
  }
}

Con el .env real ignorado y el .env.ejemplo versionado con valores evidentemente falsos:

.env
.env.*
!.env.ejemplo
*.pem
*.key
*.p12
id_rsa
id_ed25519
credenciales*.json

Ese bucle de validación al arrancar es más importante de lo que parece: convierte un fallo silencioso en producción ("¿por qué no llegan los correos?") en un error inmediato y explícito al arrancar.

Nivel 2: gestor de secretos

Para cualquier cosa que llegue a producción, el .env en el disco de una máquina se queda corto. Un gestor de secretos es un servicio que los almacena cifrados y los entrega bajo demanda a quien tiene permiso.

Ventaja Frente al .env
Cifrado en reposo El .env es texto plano en el disco
Control de acceso por identidad El .env lo lee cualquiera que entre en la máquina
Auditoría Registra quién leyó qué y cuándo
Rotación Puede cambiar el valor sin desplegar nada
Caducidad Credenciales de vida corta, generadas al vuelo
Revocación centralizada Un solo sitio donde cortar el acceso

Los hay de varias familias: los integrados en cada proveedor de nube, los autoalojados de propósito general, y los de las propias plataformas de CI (los "secretos del repositorio", que vimos usar en el flujo de la lección 07-06). Cuál elegir depende de la infraestructura; lo que importa es el principio.

Un patrón intermedio muy usado, cuando se quiere versionar la configuración cifrada: herramientas de cifrado de ficheros que guardan en el repositorio un .env.cifrado que solo se puede descifrar con una clave que está en otra parte. Es aceptable con dos condiciones: que el algoritmo sea sólido y que la clave de descifrado no esté nunca en el repositorio. Y ten presente que si esa clave se filtra algún día, todo el historial cifrado queda expuesto retroactivamente.

La regla del mínimo privilegio

Independientemente de dónde estén, las credenciales deben tener el permiso mínimo y la vida más corta posible:

  • Un token de CI que solo necesita leer paquetes no debe poder escribir en el repositorio.
  • Una credencial de desarrollo nunca debe servir para producción.
  • Los tokens con caducidad son preferibles a los permanentes, aunque den más trabajo.

Así, cuando ocurra una fuga —y ocurrirá—, el daño está acotado desde antes.

  1. Detección: hooks de cliente y análisis en el CI

La mejor fuga es la que no llega a confirmarse. Hay tres barreras, en orden de cercanía al desarrollador.

Barrera 1: hook pre-commit

Retomando la lección 06-01, un hook que rechaza el commit si detecta patrones sospechosos en lo que está a punto de entrar:

#!/usr/bin/env bash
# .githooks/pre-commit — detección básica de secretos
# Analiza SOLO lo preparado, que es lo que va a entrar en el commit.

fallos=0

# 1. Ficheros que nunca deben confirmarse, por nombre
patrones_prohibidos='(^|/)\.env$|(^|/)\.env\.[^e]|\.pem$|\.p12$|\.pfx$|(^|/)id_rsa$|(^|/)id_ed25519$|credenciales.*\.json$'

while IFS= read -r fichero; do
  if echo "$fichero" | grep -qE "$patrones_prohibidos"; then
    echo "BLOQUEADO: '$fichero' parece contener credenciales." >&2
    fallos=1
  fi
done < <(git diff --cached --name-only --diff-filter=ACM)

# 2. Contenido sospechoso en lo preparado
#    Patrones genéricos: asignaciones de valores largos a nombres reveladores
if git diff --cached -U0 | grep -nE \
   '^\+.*(contrasena|password|passwd|secret|api[_-]?key|token|private[_-]?key)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{12,}' ; then
  echo "" >&2
  echo "BLOQUEADO: posible credencial en el contenido preparado." >&2
  fallos=1
fi

# 3. Bloques de clave privada
if git diff --cached | grep -q 'BEGIN [A-Z ]*PRIVATE KEY'; then
  echo "BLOQUEADO: bloque de clave privada detectado." >&2
  fallos=1
fi

# 4. Cadenas de conexión con credenciales embebidas
if git diff --cached -U0 | grep -nE '^\+.*[a-z][a-z0-9+.-]*://[^/[:space:]]+:[^@[:space:]]+@'; then
  echo "BLOQUEADO: URL con credenciales embebidas." >&2
  fallos=1
fi

if [ "$fallos" -ne 0 ]; then
  echo "" >&2
  echo "Si es un falso positivo, revísalo con cuidado antes de usar --no-verify." >&2
  echo "Si es real: NO uses --no-verify. Saca el valor a una variable de entorno." >&2
  exit 1
fi

exit 0
chmod +x .githooks/pre-commit
git config --local core.hooksPath .githooks

Existen herramientas especializadas mucho mejores que este script —con cientos de patrones específicos por proveedor, cálculo de entropía y listas de exclusión— y se integran como hook con gestores tipo Husky, tal como vimos en la lección 06-01. El script de arriba sirve para entender el mecanismo y como red mínima.

Y la advertencia de siempre, que la lección 07-06 convirtió en principio: --no-verify lo esquiva. Un hook de cliente no es un control de seguridad. Es una ayuda para la persona honrada con prisa, que es exactamente el perfil que filtra secretos.

Barrera 2: análisis en el CI

Aquí sí hay control, porque se ejecuta donde el desarrollador no manda (lección 07-06):

# .github/workflows/ci.yml (fragmento)
  secretos:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # el análisis necesita el historial

      - name: Buscar secretos en los commits de la PR
        run: |
          BASE="${{ github.event.pull_request.base.sha }}"
          # Aquí iría la herramienta especializada de detección.
          # Como red mínima, una comprobación de ficheros prohibidos:
          if git diff --name-only "$BASE..HEAD" \
             | grep -E '\.env$|\.pem$|id_rsa$|\.p12$'; then
            echo "::error::Se ha añadido un fichero que parece contener credenciales"
            exit 1
          fi

      - name: Comprobar que .env.ejemplo está al día
        run: |
          # Las claves que el código lee del entorno deben estar documentadas
          grep -ohE 'process\.env\.[A-Z_]+' -r . --include='*.js' \
            | sed 's/process\.env\.//' | sort -u > /tmp/usadas.txt
          grep -oE '^[A-Z_]+' .env.ejemplo | sort -u > /tmp/documentadas.txt
          if ! comm -23 /tmp/usadas.txt /tmp/documentadas.txt | grep -q .; then
            echo "Todas las variables están documentadas."
          else
            echo "::error::Faltan variables en .env.ejemplo:"
            comm -23 /tmp/usadas.txt /tmp/documentadas.txt
            exit 1
          fi

Declarada como comprobación obligatoria en la rama protegida, esta barrera sí impide que el commit llegue a main. Pero atención al matiz: el commit ya existe en la rama publicada. La detección en CI evita que entre en la línea principal; no evita que el secreto ya esté en el servidor. Por eso las tres barreras se complementan y ninguna sobra.

Barrera 3: análisis periódico del historial completo

Las dos anteriores miran lo nuevo. De vez en cuando conviene mirar lo viejo, porque las reglas de detección mejoran y porque el historial antiguo se escribió antes de que hubiera ninguna barrera:

# Buscar un patrón en todo el historial, en todas las ramas
git log --all -p -S 'API_CORREO_CLAVE' --oneline

# Buscar en el contenido de todos los commits alcanzables
git rev-list --all | xargs git grep -n 'BEGIN RSA PRIVATE KEY' 2>/dev/null

# Ver el contenido de un fichero en un commit concreto
git show 4c8a9f0:config.js

Programa este análisis en el CI con una ejecución semanal. Es barato y encuentra lo que se coló antes de que existieran las reglas.

  1. Si ya se filtró: el procedimiento por orden

Ha pasado. En gestor-tareas, Ana ejecutó el análisis del historial y encontró esto:

git log --all --oneline -- config.js
7c2d9e1 chore: elimina config.js del repositorio
4c8a9f0 chore: configuración inicial de la base de datos
git show 4c8a9f0:config.js
module.exports = {
  baseDatos: {
    host: "bd-interna.ejemplo.es",
    usuario: "gestor_app",
    contrasena: "una-clave-ficticia-de-ejemplo",
  },
  apiCorreo: "clave-de-ejemplo-no-real",
};

El commit 7c2d9e1 "eliminó" el fichero hace seis meses. El secreto sigue perfectamente accesible. Y el repositorio lleva cuatro meses siendo público.

El procedimiento tiene cuatro pasos y el orden no es negociable:

flowchart TD
    A["1. ROTAR la credencial<br/>INMEDIATAMENTE"] --> B["2. Reescribir el historial<br/>git filter-repo"]
    B --> C["3. Coordinar con el equipo<br/>todos los clones quedan obsoletos"]
    C --> D["4. Pedir purga de cachés<br/>y asumir que quedan copias"]
    A -.->|"lo único que<br/>de verdad neutraliza"| A

La razón del orden es una sola frase, y conviene grabársela:

Reescribir el historial primero es el error clásico. Tarda horas, requiere coordinar a todo el equipo, y durante todo ese tiempo la credencial sigue siendo válida. Además, el proceso de reescritura y el aviso al equipo hacen mucho ruido: si alguien está observando, le acabas de señalar exactamente dónde mirar. Primero se invalida el secreto; luego, con calma, se limpia.

  1. Paso 1 — Rotar la credencial

Rotar significa invalidar el valor filtrado y generar uno nuevo. Es lo único que de verdad neutraliza la fuga, porque es lo único que no depende de controlar copias que no controlas.

Lo primero, en los primeros minutos:

  1. Revoca o cambia la credencial en el servicio de origen. No "la cambiaré cuando despliegue": ahora. Si es una clave de API, revócala en el panel del proveedor. Si es una contraseña de base de datos, cámbiala. Si es una clave SSH, elimina la pública autorizada.
  2. Genera la nueva y guárdala donde corresponde (apartado 3), nunca en el repositorio.
  3. Despliega la nueva en los entornos que la necesiten.
  4. Revisa los registros de acceso del servicio desde la fecha del commit filtrado. Es la única forma de saber si se usó. En el ejemplo, desde hace ocho meses.
  5. Documenta el incidente: qué se filtró, desde cuándo, qué se hizo y cuándo. Es imprescindible en un entorno regulado y muy útil en cualquiera.

El orden dentro del paso 1

Si el servicio lo permite, crea primero la credencial nueva, despliégala y revoca la vieja después. Así no hay corte de servicio. Si no lo permite, o si hay indicios de uso indebido, revoca primero y asume el corte: una interrupción de diez minutos es infinitamente preferible a una credencial viva en un repositorio público.

La conversación incómoda

Si el repositorio ha sido público, hay que asumir lo peor: la credencial se ha comprometido, con independencia de que los registros no muestren nada raro. Los escáneres automáticos operan en minutos. Actúa como si se hubiera usado y revisa qué se pudo hacer con ella.

Y aquí es donde el aviso del principio se vuelve concreto: si esa credencial daba acceso a datos personales o a sistemas con requisitos regulatorios, la notificación de la brecha puede tener un plazo legal. Informa a tu responsable de seguridad o de cumplimiento antes de seguir con el paso 2. No es un trámite burocrático: los plazos empiezan a contar desde que se detecta.

  1. Paso 2 — Reescribir el historial con git filter-repo

Con la credencial ya muerta, se puede limpiar el historial con calma. El objetivo es que el valor filtrado deje de existir en los objetos del repositorio.

La herramienta

git filter-repo es la herramienta recomendada actualmente. Sustituye al antiguo git filter-branch, que sigue existiendo pero es muy lento y tiene tantas trampas que el propio Git desaconseja su uso en su documentación. La alternativa es BFG Repo-Cleaner, más simple y rápida para los dos casos habituales (borrar ficheros y sustituir cadenas), aunque menos flexible.

# Instalación (varía según el sistema)
pip install git-filter-repo
# o el paquete de tu distribución

Antes de empezar: dos precauciones

# 1. Copia de seguridad completa. No es opcional.
cd ~/proyectos
cp -r gestor-tareas gestor-tareas-copia-$(date +%Y%m%d)

# 2. Trabaja sobre un clon FRESCO y sin filtros
#    (filter-repo se niega a operar sobre un repositorio "sucio")
git clone --mirror ssh://[email protected]/equipo/gestor-tareas.git limpieza.git
cd limpieza.git

--mirror clona todas las referencias: ramas, etiquetas, notas. Es lo correcto aquí, porque el secreto puede estar en una rama antigua o en una etiqueta.

Caso A: eliminar un fichero de todo el historial

# Elimina config.js de TODOS los commits, en todas las ramas
git filter-repo --path config.js --invert-paths
Parsed 847 commits
New history written in 3.21 seconds...
Completely finished after 4.87 seconds.

Se puede aplicar a varios ficheros o a directorios enteros:

git filter-repo \
  --path config.js \
  --path .env \
  --path credenciales/ \
  --invert-paths

Caso B: sustituir el valor, conservando el fichero

A menudo el fichero debe seguir existiendo; lo que sobra es el valor. --replace-text toma un fichero de reglas:

cat > /tmp/reemplazos.txt <<'EOF'
una-clave-ficticia-de-ejemplo==>***ELIMINADO***
clave-de-ejemplo-no-real==>***ELIMINADO***
regex:contrasena:\s*"[^"]*"==>contrasena: "***ELIMINADO***"
EOF

git filter-repo --replace-text /tmp/reemplazos.txt

Formato de cada línea: literal==>sustituto, o regex:expresión==>sustituto. Si se omite ==>sustituto, se usa ***REMOVED***.

Ese fichero de reglas contiene los secretos en texto plano. Bórralo en cuanto termines:

shred -u /tmp/reemplazos.txt 2>/dev/null || rm -f /tmp/reemplazos.txt

Comprobar el resultado

# El fichero ya no debe aparecer en ningún commit
git log --all --oneline -- config.js       # sin salida

# Ni el valor en ningún contenido
git rev-list --all | xargs git grep -n 'una-clave-ficticia' 2>/dev/null   # sin salida

# Los objetos huérfanos, fuera (ver lección 08-06)
git reflog expire --expire=now --all
git gc --prune=now --aggressive

Publicar la reescritura

filter-repo elimina deliberadamente el remoto origin como medida de seguridad, para que no puedas publicar sin pensarlo. Hay que volver a añadirlo:

git remote add origin ssh://[email protected]/equipo/gestor-tareas.git

# Esto reescribe TODAS las ramas y etiquetas del servidor.
# Requiere quitar temporalmente la protección de main.
git push --force --all
git push --force --tags

Este es el momento más delicado de todo el procedimiento. Estás reescribiendo la historia publicada de todo el proyecto, exactamente lo que la regla de oro de la lección 05-01 prohíbe. Está justificado —es una de las poquísimas excepciones legítimas—, pero exige la coordinación del paso 3. Con --mirror y --force --all, --force-with-lease no aplica del mismo modo; la protección aquí es organizativa: nadie envía nada durante la ventana acordada.

Los efectos que hay que asumir

Efecto Detalle
Todos los SHA cambian A partir del primer commit modificado. Los enlaces a commits en tickets, documentación y PRs dejan de funcionar
Todos los clones quedan obsoletos Ana, Bruno, Carla y Diego tienen historias divergentes
Las PRs abiertas se rompen Apuntan a commits que ya no existen
Las etiquetas se reescriben Las anotadas y firmadas pierden la firma (apartado 12)
.git-blame-ignore-revs queda obsoleto Los SHA que contiene ya no existen; hay que regenerarlo
El CI puede fallar Cachés con claves basadas en SHA que ya no existen

  1. Paso 3 — Coordinar con el equipo

Reescribir sin avisar convierte un incidente controlado en un caos. La secuencia:

Antes

  1. Anuncia la ventana con antelación, por el canal que todo el mundo lea.
  2. Que todos fusionen o cierren sus ramas. Lo que quede abierto habrá que rehacerlo.
  3. Que todos hagan push de lo que tengan y luego paren: nadie envía nada durante la ventana.
  4. Quita temporalmente la protección de main (necesaria para el --force), y anótalo para restaurarla.

Después: instrucciones para el equipo

La opción recomendada y más segura es volver a clonar:

# 1. Guarda cualquier trabajo pendiente fuera del repositorio
cd ~/proyectos/gestor-tareas
git diff > ~/mis-cambios-pendientes.patch     # por si acaso

# 2. Renombra el clon viejo (no lo borres todavía)
cd ~/proyectos
mv gestor-tareas gestor-tareas-VIEJO

# 3. Clona de nuevo
git clone ssh://[email protected]/equipo/gestor-tareas.git
cd gestor-tareas

# 4. Reconfigura lo local que no viaja (lecciones 08-01 a 08-04)
git config --local core.hooksPath .githooks
git config --local commit.template .gitmensaje
git config --local blame.ignoreRevsFile .git-blame-ignore-revs

# 5. Cuando todo funcione, borra el viejo
rm -rf ~/proyectos/gestor-tareas-VIEJO

Si alguien tiene trabajo sin publicar que quiere rescatar, se rebasa sobre el historial nuevo:

# En el clon nuevo, traer los commits del viejo
git remote add viejo ~/proyectos/gestor-tareas-VIEJO
git fetch viejo
git switch -c GT-155-recuperada viejo/GT-155
git rebase --onto main $(git merge-base main viejo/GT-155) GT-155-recuperada

Puede haber conflictos: los commits que se rebasan se escribieron sobre una base que ya no existe. Con ramas cortas —el argumento del módulo 7 a favor de integrar a menudo— el coste es pequeño; con una rama de tres semanas, es doloroso. Es un argumento más a favor de las ramas cortas.

El caso de Diego

Diego tiene un fork, es decir, un repositorio independiente en el servidor. La reescritura de origin no toca su fork: allí el secreto sigue intacto, y con él en todos los clones de su fork.

# Diego, en su fork (retomando el triángulo de la lección 07-01)
git remote -v
origin    ssh://[email protected]/diego/gestor-tareas.git (fetch)
upstream  ssh://[email protected]/equipo/gestor-tareas.git (fetch)

Hay que pedirle explícitamente que borre su fork y lo vuelva a crear desde el repositorio ya limpio, o que aplique la misma reescritura sobre él. Y lo mismo con cualquier otro fork existente: en un repositorio público puede haber decenas que ni siquiera conoces. Es una de las razones por las que el paso 1 —rotar— es lo único que realmente cierra el asunto.

Después de todo

  1. Restaura la protección de main y comprueba que quedó como estaba.
  2. Regenera .git-blame-ignore-revs con los SHA nuevos.
  3. Verifica el CI y limpia las cachés que dependan de SHA antiguos.
  4. Comprueba de nuevo que el secreto no está: git rev-list --all | xargs git grep ....

  1. Paso 4 — Las copias que quedan en las plataformas

Este paso es el que más gente omite, y es el que explica por qué el paso 1 es innegociable.

Aunque hayas reescrito el historial y forzado el envío, en las plataformas de alojamiento pueden quedar copias accesibles:

Dónde queda Por qué
Vista de commits por SHA Muchas plataformas conservan los objetos "huérfanos" y los sirven si conoces el SHA, a veces durante mucho tiempo
Pull requests cerradas Guardan una copia de los commits que se propusieron, incluso si la rama se borró
Comentarios de revisión Citan fragmentos de código literalmente; si el secreto estaba en una línea comentada, ahí sigue
Forks Repositorios independientes, con su propia copia
Cachés y CDN de la plataforma Páginas y paquetes generados, servidos desde caché
Cachés del CI Clones y artefactos de ejecuciones anteriores
Índices de motores de búsqueda Si era público, el contenido puede estar indexado
Servicios de archivo de terceros Réplicas y espejos automáticos de repositorios públicos

Qué hacer:

  1. Contacta con el soporte de la plataforma y pide la purga de las referencias en caché y de los objetos huérfanos. Casi todas tienen un procedimiento para esto; suele requerir una petición explícita.
  2. Revisa y borra las pull requests que contengan el secreto en su diff o en sus comentarios, si la plataforma lo permite.
  3. Enumera los forks conocidos y pide su eliminación o limpieza.
  4. Limpia las cachés del CI.
  5. Y sobre todo: asume que alguna copia sobrevivirá. Siempre.

Esta es la razón de fondo del orden del procedimiento. No puedes garantizar que el secreto desaparezca de todas partes. Lo único que sí controlas al 100 % es hacer que el valor deje de servir para nada. Por eso rotar es el paso 1, y por eso los otros tres pasos son higiene importante pero secundaria.

  1. Por qué git rm no sirve

Merece un apartado propio porque es el malentendido más extendido y más peligroso, y porque lo dejamos anunciado en la lección 08-03.

git rm config.js
git commit -m "chore: elimina el fichero de configuración con credenciales"
git push

Lo que ha pasado:

  • El fichero ya no está en la punta de la rama.
  • El fichero sigue en el commit 4c8a9f0 y en todos los intermedios.
  • El blob con el contenido sigue en la base de objetos.
  • Todos los clones siguen teniéndolo.
# La demostración
git log --all --oneline -- config.js
git show 4c8a9f0:config.js          # ahí está, íntegro

Recuerda el modelo de datos de la lección 01-04: cada commit apunta a un árbol, y ese árbol apunta a los blobs de esa versión. Borrar el fichero crea un commit nuevo cuyo árbol ya no lo incluye. Los árboles anteriores no se modifican: son inmutables. El blob sigue existiendo, referenciado por ellos.

Es peor que no hacer nada, por dos motivos:

  1. Da una falsa sensación de seguridad. "Ya lo he quitado" es la frase que hace que nadie rote la credencial.
  2. Señala dónde mirar. Un commit llamado "elimina el fichero con credenciales" es una invitación a buscar en el commit anterior.
Comando Quita de la punta Quita del historial Neutraliza el secreto
git rm No No
git rm --cached Sí (del índice) No No
git filter-repo Sí (en tu repositorio) No (quedan copias)
Rotar la credencial

La última fila es la lección entera resumida en una tabla.

  1. Autenticación: claves SSH, ssh-agent y gestores de credenciales

En la lección 04-03 vimos los métodos de autenticación con el remoto y dejamos pendiente la parte de seguridad. Aquí está.

Claves SSH con frase de paso

Una clave SSH sin frase de paso es un fichero que da acceso completo al repositorio a cualquiera que lo copie. Con frase de paso, el fichero por sí solo no sirve.

# Generar una clave moderna, con frase de paso
ssh-keygen -t ed25519 -C "[email protected]"
# Enter passphrase: (escribe una frase larga, no la dejes vacía)

ed25519 es preferible a RSA: más corta, más rápida y con seguridad equivalente o mejor. Si necesitas compatibilidad con sistemas antiguos, rsa con -b 4096.

ssh-agent: la frase de paso una vez por sesión

La objeción a la frase de paso es que hay que escribirla cada vez. ssh-agent la guarda en memoria, descifrada, durante la sesión:

# Arrancar el agente (normalmente ya está en marcha)
eval "$(ssh-agent -s)"

# Añadir la clave: pide la frase UNA vez
ssh-add ~/.ssh/id_ed25519

# Con caducidad de 8 horas, que es mejor práctica
ssh-add -t 8h ~/.ssh/id_ed25519

# Ver qué claves tiene cargadas
ssh-add -l

# Descargarlas todas (al terminar la jornada, o al dejar el portátil)
ssh-add -D

En macOS, el llavero del sistema puede guardarla entre reinicios:

ssh-add --apple-use-keychain ~/.ssh/id_ed25519

Configuración recomendada en ~/.ssh/config:

Host git.ejemplo.es
    User git
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
    AddKeysToAgent yes

IdentitiesOnly yes evita que SSH ofrezca todas tus claves al servidor, lo que además previene bloqueos por exceso de intentos.

Permisos de los ficheros

SSH se niega a usar una clave con permisos laxos, y hace bien:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

Gestores de credenciales frente a store

Para HTTPS con token, la lección 04-03 presentó credential.helper y advirtió sobre store. Ahora la comparación completa:

Ayudante Dónde guarda Cifrado Recomendación
store ~/.git-credentials, texto plano No Evítalo. Cualquier proceso que lea tu $HOME obtiene el token
cache Memoria, con caducidad No hace falta (no toca el disco) Aceptable para uso puntual
manager (Git Credential Manager) Llavero del sistema Recomendado, multiplataforma
osxkeychain Llavero de macOS Recomendado en macOS
libsecret Llavero de GNOME/KDE Recomendado en Linux de escritorio
wincred Gestor de credenciales de Windows Recomendado en Windows
# Comprobar qué tienes configurado
git config --get credential.helper

# Si es "store", cámbialo YA
git config --global credential.helper manager       # multiplataforma
git config --global credential.helper osxkeychain   # macOS (Bruno)
git config --global credential.helper libsecret     # Linux (Ana)
git config --global credential.helper manager       # Windows (Carla)

# Y revisa si ha quedado algo en el fichero de texto plano
cat ~/.git-credentials 2>/dev/null   # si tiene contenido, ese token está expuesto

Si encuentras tokens en ~/.git-credentials, rótalos (paso 1 del apartado 6) y borra el fichero. Han estado en texto plano en tu disco todo este tiempo.

Sobre los tokens de acceso personal

  • Mínimo privilegio: solo los permisos que necesitas.
  • Caducidad corta: renovarlos es una molestia menor comparada con un token permanente filtrado.
  • Uno por herramienta: si se compromete uno, revocas ese y no todos.
  • Nunca en la URL del remoto: https://usuario:[email protected]/... deja el token en .git/config, en texto plano y visible en cualquier git remote -v que pegues en un chat.
# Comprobar que no tienes ninguna URL con credenciales embebidas
git config --get-regexp '^remote\..*\.url' | grep '@' | grep -v '^remote.[a-z]*.url ssh://git@'

  1. Firmar commits y etiquetas

Hay un hecho incómodo sobre Git que conviene decir con claridad:

El autor de un commit es un campo de texto que tú mismo escribes. No hay ninguna verificación.

git -c user.name="Ana Ferrer" -c user.email="[email protected]" \
    commit -m "feat: añade una puerta trasera"

Ese commit aparece a nombre de Ana en git log, en git blame y en la plataforma. Cualquiera con permiso de escritura —o cualquiera que envíe una PR— puede hacerlo. La firma criptográfica es la respuesta.

Con GPG

# 1. Generar una clave
gpg --full-generate-key
# Tipo: RSA and RSA (o ECC) · Tamaño: 4096 · Caducidad: 2y
# Nombre y correo: los MISMOS que en user.name y user.email

# 2. Localizar su identificador
gpg --list-secret-keys --keyid-format=long
sec   ed25519/A1B2C3D4E5F6A7B8 2026-08-01 [SC] [caduca: 2028-08-01]
      Huella = ....
uid   Ana Ferrer <[email protected]>
# 3. Configurar Git
git config --global user.signingkey A1B2C3D4E5F6A7B8
git config --global commit.gpgsign true      # firmar SIEMPRE
git config --global tag.gpgSign true         # y las etiquetas

# 4. Exportar la clave pública para subirla a la plataforma
gpg --armor --export A1B2C3D4E5F6A7B8

Si el agente de GPG no encuentra el terminal para pedir la frase de paso:

export GPG_TTY=$(tty)     # añádelo a tu ~/.bashrc o ~/.zshrc

Con SSH (más simple, Git 2.34+)

Desde Git 2.34 se puede firmar con la misma clave SSH que ya usas para autenticarte. Es notablemente más sencillo de montar:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgSign true

Para que la verificación local funcione, hace falta un fichero de firmantes permitidos:

cat > ~/.ssh/firmantes_permitidos <<'EOF'
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...clave-publica-de-ana
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...clave-publica-de-bruno
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...clave-publica-de-carla
EOF

git config --global gpg.ssh.allowedSignersFile ~/.ssh/firmantes_permitidos

Verificar

# Un commit concreto
git show --show-signature HEAD

# El historial con el estado de firma
git log --show-signature -3

# En formato compacto: %G? da G (buena), B (mala), U (desconocida), N (sin firma)
git log --pretty="%h %G? %an %s" -10
9f8e7d6 G Ana Ferrer   feat(filtros): añade el filtro por etiqueta
7c6b5a4 G Bruno Salas  fix(sync): evita duplicar tareas
4d3c2b1 N Carla Vidal  docs: actualiza el README

La N de Carla indica que ese commit no está firmado.

Y para las etiquetas, cerrando lo de la lección 05-05:

git tag -s v1.5.0 -m "Versión 1.5.0"
git tag -v v1.5.0

Qué garantiza una firma y qué no

Garantiza No garantiza
Que quien controla esa clave creó el commit Que el código sea correcto o seguro
Que el contenido no se ha alterado desde la firma Que la clave no esté comprometida
Trazabilidad para una auditoría Que la persona sea quien dice ser (eso depende de cómo se verificó la clave)
Que un tercero no pueda suplantar a un firmante Que el commit haya sido revisado

Las limitaciones prácticas que hay que conocer:

  • Firmar no sustituye a la revisión. Un commit firmado y malicioso sigue siendo malicioso.
  • Las claves se comprometen. Por eso caducan y por eso existen los certificados de revocación (genera el tuyo al crear la clave y guárdalo en sitio seguro).
  • Reescribir el historial destruye las firmas. Un rebase o un filter-repo genera commits nuevos, sin firma. Es un efecto colateral del paso 2 del apartado 7 que hay que anticipar.
  • El squash pierde las firmas de los commits originales. Enlaza con la política de integración de la lección 08-02: si la firma es un requisito de auditoría, el squash del servidor firmado por la plataforma no es lo mismo que los commits firmados por sus autores.
  • Exigir firma a colaboradores externos tiene coste. A Diego habría que pedirle que monte GPG o SSH signing antes de su primera PR. Es razonable en un proyecto con requisitos de auditoría y una barrera innecesaria en un proyecto pequeño.

Se puede exigir la firma en las ramas protegidas de la plataforma, junto a las comprobaciones de la lección 07-06.

  1. Higiene: permisos, ramas protegidas y --force

Permisos

  • Mínimo privilegio también para las personas. Diego no necesita permiso de escritura: trabaja desde su fork y eso es exactamente lo correcto (lección 07-01).
  • Revisa los accesos periódicamente. La gente cambia de equipo y de empresa; los permisos rara vez se retiran solos. Una revisión trimestral basta.
  • Cuidado con las cuentas de servicio y los tokens de despliegue. Suelen tener permisos amplios y no caducar nunca, y nadie los revisa porque "funcionan".
  • Las aplicaciones y integraciones de terceros conectadas al repositorio también tienen acceso. Audita qué hay conectado y retira lo que no se use.

Ramas protegidas

Retomando la lección 07-06, una rama protegida bien configurada evita accidentes y ataques:

Regla Qué previene
Prohibir push directo Que alguien se salte la revisión
Exigir PR con aprobaciones Que un cambio entre sin que nadie lo mire
Exigir comprobaciones en verde Que entre código que no compila o con secretos detectados
Prohibir --force Que se reescriba el historial publicado
Prohibir el borrado de la rama Un accidente irreversible
Exigir commits firmados Suplantación de autoría
Exigir que la rama esté al día Conflictos semánticos

--force

Recuerda de la lección 04-05: --force sobrescribe lo que haya en el servidor sin mirar. Si Bruno envió algo mientras tú trabajabas, desaparece.

# NUNCA por costumbre
git push --force

# SIEMPRE esto en su lugar
git push --force-with-lease

--force-with-lease comprueba que el estado remoto es el que tú creías; si alguien ha enviado algo, la operación se rechaza. Puedes hacerlo permanente con un alias:

git config --global alias.pushf 'push --force-with-lease'

La única excepción legítima al --force puro es la del apartado 7: la reescritura coordinada tras una filtración, con la ventana acordada y todo el mundo parado.

Un detalle que se olvida: los datos del autor

git log -1 --pretty="%an <%ae>"

Si trabajas en un proyecto público con tu correo corporativo, o al revés, revisa qué estás publicando. Puedes usar configuración por directorio (lección 01-05):

# ~/.gitconfig
[includeIf "gitdir:~/proyectos/trabajo/"]
    path = ~/.gitconfig-trabajo
[includeIf "gitdir:~/proyectos/personal/"]
    path = ~/.gitconfig-personal

  1. Antes de hacer público un repositorio

Hacer público un repositorio privado es una operación irreversible en la práctica: aunque lo vuelvas a privado en cinco minutos, hay que asumir que alguien lo clonó. La lista de comprobación:

# 1. Buscar patrones de secretos en TODO el historial
git rev-list --all | xargs git grep -nE \
  '(contrasena|password|passwd|secret|api[_-]?key|token)[[:space:]]*[=:]' 2>/dev/null | head -50

# 2. Bloques de clave privada
git rev-list --all | xargs git grep -n 'BEGIN [A-Z ]*PRIVATE KEY' 2>/dev/null

# 3. URLs con credenciales embebidas
git rev-list --all | xargs git grep -nE '[a-z]+://[^/[:space:]]+:[^@[:space:]]+@' 2>/dev/null

# 4. Ficheros sospechosos que hayan existido alguna vez
git log --all --pretty=format: --name-only --diff-filter=A \
  | sort -u | grep -iE '\.env|\.pem$|\.key$|id_rsa|credencial|secret'

# 5. Los ficheros más grandes (a menudo son volcados de datos)
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
  | awk '$1=="blob" {print $3, $4}' | sort -rn | head -20

Y la revisión manual:

  • [ ] ¿Hay correos personales o direcciones internas en los mensajes de commit?
  • [ ] ¿Hay nombres de host, IPs internas o rutas de red en la configuración?
  • [ ] ¿Hay volcados de base de datos con datos reales?
  • [ ] ¿Hay capturas de pantalla con datos identificables?
  • [ ] ¿Hay comentarios de código que revelen vulnerabilidades conocidas y sin corregir?
  • [ ] ¿El README.md describe infraestructura interna?
  • [ ] ¿Hay ficheros de configuración de CI con nombres de secretos que den pistas?
  • [ ] ¿La licencia es la correcta y está el fichero LICENSE?

En un entorno regulado, este checklist no lo firma quien escribió el código. Debe revisarlo el responsable de seguridad o de cumplimiento antes de publicar. No es burocracia: es la única forma de que alguien con criterio distinto mire lo mismo.

Errores Comunes y Consejos

Error 1: creer que git rm elimina un secreto. Es el error central de la lección. Solo lo quita de la punta; el historial y todos los clones lo conservan.

Error 2: reescribir el historial antes de rotar. Es el orden inverso al correcto. La reescritura tarda horas, hace ruido y durante todo ese tiempo la credencial sigue viva. Rotar primero, siempre.

Error 3: no rotar porque "los registros no muestran accesos raros". La ausencia de evidencia no es evidencia de ausencia. Si estuvo en un repositorio público, se considera comprometida.

Error 4: reescribir sin avisar al equipo. Todos los clones quedan divergentes, las PRs abiertas se rompen y alguien acabará reintroduciendo el historial viejo con un push desde su clon antiguo.

Error 5: olvidar los forks. La reescritura de origin no toca el fork de Diego. Si el repositorio es público, puede haber forks que ni siquiera conoces.

Error 6: seguir con credential.helper store. Guarda los tokens en texto plano en tu $HOME. Cámbialo por el gestor de tu sistema y rota lo que hubiera dentro.

Error 7: claves SSH sin frase de paso. El fichero por sí solo da acceso completo. ssh-agent elimina la incomodidad de escribirla.

Error 8: token en la URL del remoto. Queda en .git/config en texto plano y aparece en cualquier git remote -v que alguien pegue en un chat.

Error 9: valores reales en .env.ejemplo. Rompe todo el patrón: el fichero versionado pasa a ser el que tiene el secreto.

Error 10: confiar en el hook pre-commit como control. --no-verify lo esquiva. Es ayuda, no control; el control está en el CI sobre rama protegida.

Consejo 1: haz el análisis del historial completo hoy. Los comandos del apartado 14 tardan segundos. Puede que encuentres algo de hace años.

Consejo 2: mínimo privilegio y caducidad corta en todas las credenciales. Cuando ocurra la fuga —ocurrirá—, el daño estará acotado desde antes.

Consejo 3: monta la detección antes de necesitarla. Un hook pre-commit más un análisis en CI cuestan una tarde y evitan el procedimiento de cuatro pasos.

Consejo 4: documenta el procedimiento de incidente en el README.md. El día que ocurra, nadie va a tener la cabeza fría para improvisarlo. Escribe los cuatro pasos y a quién avisar.

Consejo 5: firma tus commits. Con SSH cuesta tres comandos y da trazabilidad real de autoría.

Consejo 6: --force-with-lease siempre, con alias. Elimina toda una categoría de accidentes.

Consejo 7: en entornos regulados, avisa antes de actuar. Los plazos de notificación empiezan al detectar, no al terminar la limpieza.

Ejercicios

Ejercicio 1: encontrar el secreto en el historial

Monta el escenario de gestor-tareas y practica la detección:

  1. Crea un repositorio y confirma un config.js con una contraseña ficticia.
  2. Añade tres commits más de trabajo normal.
  3. "Elimina" el fichero con git rm y confirma.
  4. Demuestra, con tres comandos distintos, que la contraseña sigue siendo accesible.
  5. Explica, apoyándote en el modelo de datos de la lección 01-04, por qué sigue ahí.

Ejercicio 2: el procedimiento completo

Partiendo del repositorio del ejercicio 1:

  1. Enumera por escrito los cuatro pasos del procedimiento en orden, y justifica por qué rotar va primero.
  2. Haz una copia de seguridad del repositorio.
  3. Elimina config.js de todo el historial con git filter-repo --path ... --invert-paths.
  4. Verifica que ya no aparece con los tres comandos del ejercicio 1.
  5. Repite el ejercicio con --replace-text, sustituyendo solo el valor y conservando el fichero.
  6. Compara los SHA antes y después. ¿Qué implica para el equipo? ¿Y para .git-blame-ignore-revs?
  7. Enumera qué copias del secreto seguirían existiendo aunque el proceso saliera perfecto.

Ejercicio 3: firma y verificación

  1. Configura la firma de commits con SSH (gpg.format=ssh), usando una clave nueva creada para el ejercicio.
  2. Crea el fichero de firmantes permitidos y configura gpg.ssh.allowedSignersFile.
  3. Haz un commit firmado y uno sin firmar (con --no-gpg-sign).
  4. Muestra el historial con %G? y distingue ambos.
  5. Firma una etiqueta anotada y verifícala con git tag -v.
  6. Ejecuta git rebase -i HEAD~2 con un reword y comprueba qué le ocurre a la firma. Explica la relación con el paso 2 del procedimiento de filtración.

Soluciones

Solución 1:

mkdir /tmp/practica-seg && cd /tmp/practica-seg && git init -b main
git config user.name "Ana Ferrer"; git config user.email "[email protected]"

cat > config.js <<'EOF'
module.exports = {
  baseDatos: {
    host: "bd-interna.ejemplo.es",
    usuario: "gestor_app",
    contrasena: "una-clave-ficticia-de-ejemplo",
  },
};
EOF
echo "console.log('gestor-tareas');" > app.js
git add . && git commit -m "chore: configuración inicial de la base de datos"
# 2. Trabajo normal encima
for i in 1 2 3; do
  echo "// cambio $i" >> app.js
  git commit -am "feat(app): cambio $i"
done

# 3. "Eliminar" el fichero
git rm config.js && git commit -m "chore: elimina config.js del repositorio"
ls    # config.js ya no está en el disco
# 4a. El historial del fichero
git log --all --oneline -- config.js
7c2d9e1 chore: elimina config.js del repositorio
4c8a9f0 chore: configuración inicial de la base de datos
# 4b. El contenido en un commit concreto
git show 4c8a9f0:config.js
module.exports = {
  baseDatos: {
    host: "bd-interna.ejemplo.es",
    usuario: "gestor_app",
    contrasena: "una-clave-ficticia-de-ejemplo",
  },
};
# 4c. Búsqueda ciega por contenido en todo el historial
git rev-list --all | xargs git grep -n 'una-clave-ficticia' 2>/dev/null
4c8a9f0:config.js:5:    contrasena: "una-clave-ficticia-de-ejemplo",
# Extra: el blob sigue siendo un objeto vivo
git rev-parse 4c8a9f0:config.js          # el SHA del blob
git cat-file -p $(git rev-parse 4c8a9f0:config.js)

5. Por qué sigue ahí. Según el modelo de datos de la lección 01-04, un commit apunta a un árbol, y ese árbol lista los blobs de esa versión del proyecto. Los objetos de Git son inmutables: su SHA es el hash de su contenido, así que modificarlos es imposible por construcción.

git rm no modifica nada existente: crea un commit nuevo cuyo árbol ya no incluye config.js. Los árboles de 4c8a9f0 y de los tres commits siguientes siguen exactamente igual, siguen listando el blob con la contraseña, y ese blob sigue siendo alcanzable desde main recorriendo el historial hacia atrás. Mientras un commit alcanzable lo referencie, git gc no lo eliminará jamás.

La única forma de que desaparezca es reescribir todos los commits desde el primero que lo contenía, generando árboles nuevos sin él. Eso cambia sus SHA y, en cascada, los de todos sus descendientes. Es exactamente lo que hace git filter-repo, y es la razón de que sea una operación tan disruptiva.

Solución 2:

1. Los cuatro pasos:

# Paso Por qué en este orden
1 Rotar/revocar la credencial Es lo único que la neutraliza de verdad. No depende de controlar copias que no controlas. Es rápido: minutos
2 Reescribir el historial (git filter-repo) Limpia tu repositorio. Tarda horas con la coordinación, y durante ese tiempo la credencial vieja ya no sirve
3 Coordinar con el equipo Todos los clones quedan divergentes; sin esto, alguien reintroduce el historial viejo
4 Pedir la purga en la plataforma Quedan copias en PRs, cachés y forks. Es lo último porque es lo que menos controlas

Rotar va primero por tres razones acumulativas: (a) es lo único que funciona con seguridad, ya que no puedes garantizar la eliminación de todas las copias; (b) es lo más rápido, mientras que la reescritura requiere coordinar a todo el equipo; y (c) la reescritura hace mucho ruido —avisos al equipo, commits que cambian, PRs rotas— y si alguien está observando, le señalas exactamente dónde mirar mientras la credencial sigue viva.

# 2. Copia de seguridad
cd /tmp && cp -r practica-seg practica-seg-copia

# 3. Eliminar el fichero de todo el historial
cd /tmp/practica-seg
git filter-repo --path config.js --invert-paths --force
Parsed 5 commits
New history written in 0.03 seconds...
Completely finished after 0.11 seconds.
# 4. Verificación
git log --all --oneline -- config.js                              # sin salida
git rev-list --all | xargs git grep -n 'una-clave' 2>/dev/null    # sin salida
git show 4c8a9f0:config.js
fatal: invalid object name '4c8a9f0'

El commit ni siquiera existe ya: se reescribió con otro SHA.

# 5. La variante que conserva el fichero
cd /tmp && rm -rf practica-seg2 && cp -r practica-seg-copia practica-seg2 && cd practica-seg2

cat > /tmp/reemplazos.txt <<'EOF'
una-clave-ficticia-de-ejemplo==>***ELIMINADO***
EOF

git filter-repo --replace-text /tmp/reemplazos.txt --force
rm -f /tmp/reemplazos.txt      # el fichero de reglas contenía el secreto

git log --all --oneline -- config.js     # el fichero SÍ sigue en el historial
git rev-list --all | xargs git grep -n 'ELIMINADO' 2>/dev/null
a9f8e7d:config.js:5:    contrasena: "***ELIMINADO***",

El fichero se conserva con toda su estructura; solo el valor ha desaparecido de todas las versiones.

# 6. Comparar los SHA
cd /tmp/practica-seg-copia && git log --oneline
cd /tmp/practica-seg && git log --oneline

Todos los SHA son distintos, desde el primer commit reescrito hasta la punta. Implicaciones:

  • Para el equipo: todos los clones tienen una historia divergente. Un git pull produciría un enredo enorme. Hay que volver a clonar y rehacer las ramas vivas (paso 3).
  • Para .git-blame-ignore-revs: contiene SHA que ya no existen, así que queda inservible y hay que regenerarlo con los nuevos. Lo mismo pasa con cualquier SHA citado en tickets, documentación, comentarios de PR o cachés de CI.
  • Para las firmas (apartado 12): los commits reescritos pierden su firma, porque son objetos nuevos.

7. Copias que seguirían existiendo aunque todo saliera perfecto:

  • Los clones de Ana, Bruno y Carla, hasta que vuelvan a clonar.
  • El fork de Diego, que es un repositorio independiente que la reescritura de origin no toca.
  • Los clones que otras personas hayan hecho del fork de Diego.
  • Las pull requests cerradas en la plataforma, con sus diffs.
  • Los comentarios de revisión que citaran esas líneas.
  • Las cachés de la plataforma y los objetos huérfanos accesibles por SHA.
  • Las cachés y artefactos del CI.
  • Las copias de seguridad del servidor.
  • Si fue público: índices de buscadores, servicios de archivo y escáneres automáticos.

Y ahí está la conclusión de la lección: el paso 2 limpia tu repositorio, no el mundo. Por eso el paso 1 es el único innegociable.

Solución 3:

mkdir /tmp/practica-firma && cd /tmp/practica-firma && git init -b main
git config user.name "Ana Ferrer"; git config user.email "[email protected]"

# 1. Clave nueva solo para el ejercicio
ssh-keygen -t ed25519 -f /tmp/clave-firma -N "" -C "[email protected]"

git config --local gpg.format ssh
git config --local user.signingkey /tmp/clave-firma.pub
git config --local commit.gpgsign true
git config --local tag.gpgSign true
# 2. Firmantes permitidos
echo "[email protected] $(cat /tmp/clave-firma.pub)" > /tmp/firmantes
git config --local gpg.ssh.allowedSignersFile /tmp/firmantes
# 3. Un commit firmado y uno sin firmar
echo "uno" > app.js && git add . && git commit -m "feat: primer commit firmado"
echo "dos" >> app.js && git commit -am "feat: commit sin firmar" --no-gpg-sign
# 4. Distinguirlos
git log --pretty="%h %G? %an %s"
b2c3d4e N Ana Ferrer feat: commit sin firmar
a1b2c3d G Ana Ferrer feat: primer commit firmado

G = firma buena y verificada. N = sin firma. También hay B (firma incorrecta) y U (firma buena, firmante no fiable).

git show --show-signature a1b2c3d | head -5
commit a1b2c3d...
Good "git" signature for [email protected] with ED25519 key SHA256:...
# 5. Etiqueta firmada
git tag -s v1.0.0 -m "Primera versión"
git tag -v v1.0.0
object a1b2c3d...
type commit
tag v1.0.0
...
Good "git" signature for [email protected] with ED25519 key SHA256:...
# 6. Qué pasa al reescribir
git rebase -i HEAD~2      # cambia "pick" por "reword" en el primero, guarda y edita el mensaje
git log --pretty="%h %G? %an %s"
d4e5f6a N Ana Ferrer feat: commit sin firmar
c3d4e5f G Ana Ferrer feat: primer commit firmado (reescrito)

Los SHA han cambiado. Con commit.gpgsign true, Git vuelve a firmar los commits reescritos con tu clave. Si esa configuración estuviera desactivada, o si estuvieras reescribiendo commits de otras personas, todas las firmas se perderían: los commits nuevos serían tuyos y sin firma.

La relación con el paso 2 del procedimiento de filtración es directa y hay que anticiparla: git filter-repo reescribe todos los commits afectados, generando objetos nuevos. Las firmas originales no sobreviven, porque una firma cubre el contenido exacto de un objeto concreto y ese objeto ya no existe. Después de limpiar un secreto:

  • Se pierde la trazabilidad criptográfica de autoría de todo el historial reescrito.
  • Las etiquetas firmadas de versiones anteriores dejan de verificar.
  • Si la firma es un requisito de auditoría, hay que documentar el incidente y la reescritura como parte del expediente, porque la evidencia criptográfica anterior ya no se puede reconstruir.

Es un coste real de la limpieza, y otra razón —además de todas las anteriores— para invertir en prevención: las barreras del apartado 4 son muchísimo más baratas que este procedimiento.

Conclusión

Lo esencial de esta lección:

  • Un repositorio es el peor sitio posible para un secreto: se clona entero, es inmutable por diseño, se replica sin control y es trivial de buscar. De ahí la idea que gobierna todo: la única acción que neutraliza un secreto filtrado es invalidarlo.
  • Es secreto todo lo que caduca, se rota o se puede revocar, más los datos personales y la información de infraestructura. En caso de duda, es secreto.
  • La gestión correcta saca los valores del repositorio: variables de entorno con .env ignorado y .env.ejemplo versionado para lo pequeño, gestor de secretos para lo que llega a producción, y mínimo privilegio y caducidad corta siempre.
  • La detección tiene tres barreras: el hook pre-commit (ayuda, no control: --no-verify existe), el análisis en CI sobre rama protegida (esto sí es control) y el análisis periódico del historial completo.
  • Si ya se filtró, el orden no es negociable:
    1. Rotar la credencial inmediatamente. Es lo único que de verdad funciona, y es lo más rápido.
    2. Reescribir el historial con git filter-repo (o BFG), sobre un clon --mirror, con copia de seguridad previa.
    3. Coordinar con el equipo: todos los clones quedan obsoletos, hay que volver a clonar y rehacer las ramas; y los forks son repositorios aparte que no se limpian solos.
    4. Pedir la purga en la plataforma y asumir que quedan copias en PRs cerradas, comentarios, cachés y forks.
  • git rm no borra del historial. Solo quita el fichero de la punta; el blob sigue vivo, alcanzable y presente en todos los clones. Es peor que no hacer nada, porque da falsa seguridad y señala dónde mirar.
  • En autenticación, cerrando la lección 04-03: claves SSH ed25519 con frase de paso, ssh-agent con caducidad para que no moleste, y gestor de credenciales del sistema en lugar de credential.helper store, que guarda los tokens en texto plano. Nunca credenciales en la URL del remoto.
  • Firmar commits y etiquetas (GPG o, más sencillo, gpg.format=ssh) da trazabilidad criptográfica de autoría, porque el campo de autor de un commit es texto libre. Garantiza quién firmó y que el contenido no se alteró; no garantiza que el código sea correcto ni que la clave no esté comprometida. Y la reescritura del historial destruye las firmas.
  • Higiene: mínimo privilegio también para las personas, revisión periódica de accesos, ramas protegidas bien configuradas, --force-with-lease siempre, y una lista de comprobación antes de hacer público un repositorio.
  • Y el aviso de fondo: en entornos regulados, avisa a tu responsable de seguridad o de cumplimiento antes de actuar. Los plazos de notificación empiezan al detectar, no al terminar de limpiar.

gestor-tareas ya tiene mensajes útiles, historial limpio, los ficheros correctos, tratados como corresponde, y sus secretos fuera. Queda un último asunto del módulo, mucho menos dramático pero cada vez más molesto: el repositorio se ha vuelto lento. git status tarda tres segundos en la máquina de Carla, el clon pesa 900 MB pese a que el código ocupa 4, y nadie sabe muy bien por qué.

Lo diagnosticamos y lo arreglamos en la lección 08-06: Consejos de Rendimiento.

Dominando Git: De Principiante a Avanzado

Módulo 1: Introducción a Git

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados