Secretos en el código y en git
En resumen: Un secreto (una clave o contraseña) que se subió a git se queda en el historial aunque se borre: primero se rota, después se limpia el historial, y el código lo lee de variables de entorno.
Problema
La clave que borraste
La tienda cobra a través de un proveedor. El programa le envía al proveedor una clave de API: una cadena larga que demuestra «esta es la tienda». Quien tiene la clave puede iniciar pagos y devoluciones en nombre de la tienda.
Un compañero tiene prisa y escribe la clave directamente en el código:
class PaymentConfig { static final String API_KEY = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";}Alguien lo ve en la revisión de código. El compañero la borra con un commit nuevo, «Quitar la clave del código». El archivo ya está limpio.
Tres meses después el repositorio se hace público por el SDK abierto de la tienda. La noche siguiente empiezan a salir de la cuenta de la tienda devoluciones a tarjetas de desconocidos. Alguien ejecutó un solo comando:
$ git log -p -S pay_live_commit 9c41e07 Quitar la clave del código- "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";commit 3a9f2d1 Añadir pagos+ "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";Borrar no quitó la clave. Añadió un sitio más donde aparece.
La idea
Git lo recuerda todo
Cada commit es una foto de los archivos. Un commit nuevo no cambia el viejo; se pone encima. Por eso git log -p muestra tanto cuando se añadió la clave como cuando se borró.
Un secreto que entró en git y salió de tu máquina se ha filtrado. No «quizá»: hay copias en los clones de tus compañeros, en forks, en la caché de CI, en espejos. No puedes encontrarlas, y mucho menos borrarlas.
Por eso el orden es siempre el mismo:
- Rotación. En el panel del proveedor revocas la clave vieja y emites una nueva. Ahora la vieja no sirve, esté copiada donde esté.
- Limpiar el historial.
git filter-reporeescribe cada commit sin la clave. Despuésgit push --force, y todos clonan de nuevo. - La clave nueva nunca entra en el código. El programa la lee de fuera: del entorno (variables de entorno) o de un almacén de secretos.
El orden inverso es un error: mientras limpias el historial, la clave vieja sigue funcionando.
Un secreto es todo lo que da acceso: una clave de API, una contraseña de base de datos, una clave para firmar tokens, la clave privada de un certificado. En la lección sobre entrada no confiable protegiste el programa de los datos de otros. Aquí proteges a otros de los tuyos.
Míralo
La clave en el historial
static String paymentKey() { String key = System.getenv("PAYMENT_API_KEY"); if (key == null || key.isBlank()) { throw new IllegalStateException( "PAYMENT_API_KEY no está definida"); } return key;}class PaymentConfig { static final String API_KEY = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";}static String paymentKey() { String key = System.getenv("PAYMENT_API_KEY"); if (key == null || key.isBlank()) { throw new IllegalStateException( "PAYMENT_API_KEY no está definida"); } return key;}Paso a paso, en palabras
- La clave de pagos está en el código. El commit 3a9f2d1 «Añadir pagos» la guarda en el historial.
- El siguiente commit borra estas líneas. El archivo está limpio, pero 3a9f2d1 sigue teniendo la clave.
- Alguien tiene una copia del repositorio: un clon, un fork, una caché de CI. git log -p saca la clave del commit viejo.
- Primero la rotación: el proveedor revoca la clave vieja. La copia de fuera ya no sirve.
- Después el historial: git filter-repo reescribe los commits sin la clave. Los hashes son nuevos, todos clonan de nuevo.
- La clave nueva vive solo en el entorno (o en un almacén de secretos). El código la lee por su nombre, PAYMENT_API_KEY.
- Si falta el nombre, el programa se detiene de inmediato y dice qué falta: fail closed. Sin clave por defecto.
- Con la variable definida la tienda arranca. Sin secretos en git, y la filtrada ya no funciona.
La escena empieza con el código del problema y termina con el código que lee la clave del entorno. La clave filtrada se queda en la copia de fuera, pero está revocada. El historial está reescrito: los commits tienen hashes nuevos y la clave no está en ellos.
# 1. Rotación: revocar pay_live_…6wHd en el proveedor y emitir una clave nueva.# 2. El historial: replacements.txt contiene la línea# pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd==>***REMOVED***git filter-repo --replace-text replacements.txtgit push --force --allLa idea
El secreto viene de fuera, y sin él el programa se detiene
El programa lee la clave del entorno: una tabla de nombres y valores que el sistema operativo da a cada proceso. En tu máquina la defines con export PAYMENT_API_KEY=… (en PowerShell $env:PAYMENT_API_KEY = "…"). En el servidor la define el sistema que ejecuta la aplicación, normalmente desde un almacén de secretos.
Lo importante es qué pasa cuando falta la clave. Un servidor nuevo, un nombre mal escrito, un ajuste olvidado: el programa se detiene de inmediato y dice qué falta. Eso se llama fail closed. Lo contrario, fail open, es arrancar con una clave vacía o «por defecto» y fallar con el primer cliente, o peor, funcionar con la clave equivocada.
static String paymentKey() { String key = System.getenv("PAYMENT_API_KEY"); if (key == null || key.isBlank()) { throw new IllegalStateException( "PAYMENT_API_KEY no está definida"); } return key;}La clave nunca se imprime completa, ni siquiera en el registro. Si necesitas ver qué clave está cargada, muestra solo el comienzo y los cuatro últimos caracteres:
static String masked(String key) { return key.substring(0, 9) + "…" + key.substring(key.length() - 4);}try { String key = paymentKey(); System.out.println("Los pagos están listos, clave " + masked(key));} catch (IllegalStateException e) { System.out.println("La tienda no arranca: " + e.getMessage());}La tienda no arranca: PAYMENT_API_KEY no está definidaCuando el sitio comprueba el código, esa variable no existe, así que la salida es el rechazo. En tu máquina, con la clave definida, el programa imprime Los pagos están listos, clave pay_live_…6wHd.
System.getenv devuelve null cuando el nombre no está. Aquí main atrapa la excepción solo para imprimir una frase limpia. En una aplicación real dejas que detenga el arranque: el proceso termina con un código distinto de 0, y el sistema que lo ejecuta ve que no arrancó.Míralo
Un escáner de secretos
Lo mejor es que el secreto no llegue nunca a un commit. Por eso antes de cada commit se ejecuta un escáner: un programa que busca en los cambios cadenas que parezcan secretos. Aquí tienes uno pequeño con tres reglas (expresiones regulares):
- la clave del proveedor tiene una forma conocida:
pay_live_y 24 caracteres; - una URL con una contraseña dentro:
://user:password@; - asignar una cadena de al menos 8 caracteres que no empiece por
$apassword,secretokey.
La última línea del commit es "${PAYMENT_API_KEY}": es un marcador de posición que el sistema de configuración rellena desde el entorno. No es un secreto, y el escáner no debe detenerlo.
record Rule(String name, Pattern pattern) {}
static final List<Rule> RULES = List.of( new Rule("clave de pagos", Pattern.compile("pay_live_[A-Za-z0-9]{24}")), new Rule("contraseña en una URL", Pattern.compile("://[^:/\\s]+:[^@\\s]+@")), new Rule("contraseña o clave", Pattern.compile( "(?i)(password|secret|key)\\s*=\\s*\"[^\"$]{8,}\"")));
static int scan(String text) { String[] lines = text.split("\n"); int found = 0; for (int i = 0; i < lines.length; i++) { for (Rule rule : RULES) { if (rule.pattern().matcher(lines[i]).find()) { System.out.println("línea " + (i + 1) + ": " + rule.name()); found++; break; } } } return found;}String diff = """ + payment.url = "https://api.pay.example/v1" + payment.key = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd" + db.url = "postgres://shop:Kq7vR2pX@db.internal/shop" + mail.password = "winter-sale-2026" + payment.key = "${PAYMENT_API_KEY}" """;int found = scan(diff);System.out.println(found > 0 ? "Encontrados: " + found + ". El commit se detiene." : "Limpio.");línea 2: clave de pagoslínea 3: contraseña en una URLlínea 4: contraseña o claveEncontrados: 3. El commit se detiene.Los escáneres reales tienen cientos de reglas y además miden cuán «aleatoria» es una cadena (entropía) para atrapar claves sin una forma conocida. La idea es la misma.
Pruébalo
Predice la salida
El mismo escáner, otro archivo de configuración. ¿Qué imprimirá?
String config = """ mail.user = "orders@shop.example" mail.password = "${MAIL_PASSWORD}" db.url = "postgres://shop:Kq7vR2pX@db/shop" """;int found = scan(config);System.out.println(found > 0 ? "Encontrados: " + found + ". El commit se detiene." : "Limpio.");La línea 1 es una dirección de correo: no tiene ://, y user no está en la tercera regla. La línea 2 es un marcador de posición: la tercera regla pide que la cadena no empiece por $. Queda la línea 3, donde la contraseña está en la propia URL de la base de datos.
Pruébalo
Completa
¿De dónde viene la clave?
String key = ("PAYMENT_API_KEY");
if (key == null || key.isBlank()) {
throw new IllegalStateException("PAYMENT_API_KEY no está definida");
}System.getenv(name) lee una variable de entorno o devuelve null.Pruébalo
Encuentra el error
Un compañero no quiere que la tienda «se caiga por un ajuste» y añade un valor de respaldo. ¿Qué línea es el error?
Elige la línea con el error.
La clave de respaldo sigue siendo un secreto en el código, y el programa arranca en silencio cuando falta el ajuste: fail open. En un servidor nuevo la tienda funcionará con la clave vieja, que quizá ya se filtró, y nadie lo notará. Lo correcto es detenerse con un mensaje claro.
Más precisamente, para curiosos
.gitignore y .env.example. Si los secretos locales están en un archivo .env, su nombre está en .gitignore desde el primer commit. A git va .env.example con los nombres y los valores vacíos: PAYMENT_API_KEY=. Así un compañero nuevo sabe qué rellenar sin ver los valores.
Un escáner antes de cada commit. Hacerlo a mano no funciona: la gente se olvida. El escáner se ejecuta solo desde un hook de git (pre-commit). Los ya hechos son gitleaks, trufflehog y detect-secrets, normalmente mediante el framework pre-commit. GitHub además tiene push protection: rechaza un push con la clave de un servicio conocido. El escáner es una red bajo la cuerda, no una razón para escribir secretos en el código.
Un almacén de secretos. Las variables de entorno son mejores que el código, pero no son ideales. Los procesos hijos las ven, a veces acaban en un volcado de memoria tras una caída, y es fácil imprimirlas por error. Por eso en el servidor los secretos vienen de un almacén: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager. Registran quién leyó qué y cuándo, rotan las claves según un calendario y entregan claves de corta duración.
En Spring la configuración dicepayment.key=${PAYMENT_API_KEY}, y el valor viene del entorno o del almacén (Spring Cloud Vault, AWS Secrets Manager).La limpieza, en detalle. git filter-repo quita a propósito el remoto origin después de reescribir, para que no envíes sin querer el historial nuevo al sitio equivocado: lo vuelves a añadir y ejecutas git push --force --all y --tags. Los compañeros tienen que clonar de nuevo: un clon viejo que se vuelve a fusionar devuelve la clave al historial. Servicios como GitHub además guardan referencias viejas de pull requests, así que ahí también escribes al soporte. La herramienta más antigua para el mismo trabajo es BFG Repo-Cleaner.
Secretos en CI (claves de despliegue, acceso al registro de contenedores, los secretos del pipeline) son tema del curso de DevOps ZeroToOps. Esta lección es la parte que escribe el programador.
Ejercicio
La clave en el registro
Resuélvelo en tu propio editor.
Los secretos también se filtran por los registros: la solicitud al proveedor se registra completa, con la clave dentro. Escribe clean, que sustituye en una línea del registro cada clave de pay_live_ + 24 caracteres por su forma enmascarada, y ejecútala con estas tres líneas:
POST /pay 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=49.90POST /refund 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=12.00GET /health 200POST /pay 200 key=pay_live_…6wHd amount=49.90POST /refund 200 key=pay_live_…6wHd amount=12.00GET /health 200Más tareas
- Una regla más. Añade al escáner una regla para una clave privada: una línea que empiece por
-----BEGINy termine enPRIVATE KEY-----. - Excepciones. Añade una lista de valores permitidos (por ejemplo la clave de ejemplo de la documentación) que el escáner se salte.
- Una comprobación al arrancar. Escribe una función que compruebe todos los nombres necesarios a la vez (
PAYMENT_API_KEY,DB_PASSWORD,MAIL_PASSWORD) e informe de cada uno que falte, no solo del primero.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Expresiones regulares · Listas · Lambdas · Entrada no confiable
- Continúa en: Dependencias: el código ajeno que ejecuta el tuyo · Criptografía que no escribes tú · El prompt es código · Guardrails: comprobaciones antes y después del modelo
- Ejercicios sobre el tema: Seguridad para programadores