Secret Scanning: cómo evitar que las credenciales terminen en el repositorio
Un segredo filtrado no se rompe por rotarlo. Se rompe por las consecuencias.
El problema es que la exposición suele ser acumulativa: la clave se sube en un commit, ese commit se sube a un repositorio remoto, el repositorio se clona en un servidor, el servidor genera artefactos de compilación, los artefactos se publican en un registro de imágenes y, meses después, alguien encuentra la cadena en un .env de ejemplo que nadie miró.
Detectar eso a tiempo es el objetivo del secret scanning: analizar el código y su historial en busca de credenciales antes de que lleguen a un entorno compartido.
Qué se considera un secreto
No todas las cadenas largas son credenciales, pero hay patrones que sí lo son:
| Tipo de secreto | Aspecto típico del valor |
|---|---|
| Claves de API | Prefijos reconocibles como sk-, pk-, ghp_, AKIA |
| Tokens de acceso | Cadenas aleatorias de 32 o más caracteres |
| Contraseñas | Asignaciones del tipo password=, PWD:, secret: |
| Cadenas de conexión | JDBC, MongoDB o URLs con usuario y contraseña incrustados |
| Claves privadas | Bloques BEGIN ... PRIVATE KEY |
| Tokens de servicios | Bearer tokens, tokens de Slack, de Discord o de GitHub |
Dos características ayudan a detectarlos: muchos valores tienen un prefijo conocido y una longitud fija, y casi todos los valores legítimos que se parecen a un secreto (contraseñas deliberadamente débiles, identificadores de ejemplo de la documentación del proveedor) se pueden enumerar en una lista de excepciones.
La longitud por sí sola no alcanza: cualquier hash de Git, cualquier identificador de build y muchos identificadores de paquete tienen también 40 caracteres.
Dónde se escanea
Un escaneo útil tiene que cubrir cuatro superficies distintas, porque cada una tiene su momento y su coste:
| Superficie | Cuándo | Qué encuentra |
|---|---|---|
| Espacio de trabajo | Antes de commitear, con un hook | Lo que acabas de escribir |
| Repositorio completo | En integración continua, sobre todo el historial | Secretos de hace dos años |
| Artefactos | Al publicar paquetes, imágenes o builds | Archivos .env y .map embebidos |
| Aplicación desplegada | En la aplicación, sobre lo que se sirve | Claves expuestas en el bundle del navegador |
El historial es la superficie más importante desde el punto de vista de seguridad. Un secreto que se borró en un commit posterior sigue presente en el historial de Git, y un repositorio clonado se clona completo, historial incluido. Por eso borrar el archivo no basta: hay que eliminar el objeto del repositorio y, sobre todo, rotar la credencial.
Gitleaks
Gitleaks analiza el historial de un repositorio buscando secretos mediante un conjunto de reglas que combinan expresiones regulares y cálculo de entropía.
Un archivo de configuración típico solo necesita activar las reglas por defecto y añadir excepciones propias del proyecto:
# .gitleaks.toml
title = "reglas del proyecto"
[extend]
useDefault = true
[[rules]]
id = "internal-api-token"
description = "Token interno con prefijo propio"
regex = '''INT_(?:DEV|PRD)_[A-Z0-9]{24}'''
keywords = ["INT_DEV_", "INT_PRD_"]
[rules.allowlist]
description = "Valores de ejemplo que solo viven en la documentación"
regexTarget = "match"
regexes = [
'''EXAMPLE''',
'''CHANGEME''',
'''AQUI_VA_EL_TOKEN''',
]
paths = ['''docs/.*\.md$''']
keywords acelera la búsqueda: Gitleaks descarta el archivo entero si ninguna de las palabras clave aparece en la línea. La lista de excepciones (allowlist) es la parte que más ayuda a mantener el escaneo limpio, porque permite distinguir el token real del ejemplo de la documentación del proveedor.
Uso en consola
# Escanea el historial completo del repositorio actual
gitleaks git --redact --no-banner
# Escanea el directorio de trabajo sin revisar el historial
gitleaks dir . --redact --no-banner
# Solo un rango de commits
gitleaks git 1a2b3c4..HEAD --redact --no-banner
--redact es importante en cualquier entorno donde la salida se guarde: sustituye el valor detectado por una máscara, de modo que el log del escaneo no se convierta en un nuevo lugar donde vive la credencial.
Integración continua
El escaneo tiene que ejecutarse siempre, no cuando alguien se acuerda. En GitHub Actions se puede usar la imagen oficial del proyecto, que evita tener que gestionar la instalación de la herramienta:
name: Security
on:
push:
branches: [main]
pull_request:
jobs:
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Instalar Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Escaneo del historial con Gitleaks
run: docker run -v "$PWD:/repo" zricethezav/gitleaks:latest git --redact --no-banner /repo
Dos detalles del workflow que importan:
fetch-depth: 0descarga el historial completo. Con el historial recortado, el escaneo pasa medio repositorio por delante sin avisar.- El paso de escaneo debe usar
--redacty, preferiblemente, ejecutarse sin imprimir la salida completa cuando el job corre sobre repositorios externos.
Como alternativa, la acción gitleaks/gitleaks-action ofrece el mismo resultado directamente como paso del workflow, aunque su uso fuera de organizaciones con licencia tiene sus propias condiciones.
Hook de pre-commit
El hook es la capa más barata: si el secreto nunca llega al commit, el escaneo de integración continua no tiene nada que encontrar.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
- repo: local
hooks:
- id: gitleaks
name: gitleaks
entry: gitleaks git --staged --redact --no-banner
language: system
pass_filenames: false
detect-secrets funciona con un archivo de referencia (.secrets.baseline) que registra los hallazgos ya conocidos y aceptados. Cuando aparece algo nuevo, falla y obliga a revisar; cuando se revisa y es legítimo, se acepta de forma explícita en lugar de desactivar el detector.
Es importante que el .secrets.baseline se versione en el repositorio: forma parte de la configuración del equipo y no es un archivo local.
Un escáner propio para entender qué hace la herramienta
Las herramientas comerciales resuelven el caso difícil, pero escribir un detector pequeño ayuda a entender cómo funciona el problema. El siguiente script recorre el proyecto, busca asignaciones con nombres típicos de credenciales y valores que parecen aleatorios, y mide su entropía de Shannon para descartar los textos normales.
import { readdir, readFile } from 'node:fs/promises';
import { join, relative, extname } from 'node:path';
const IGNORAR = new Set(['.git', 'node_modules', 'dist', 'build', '.next']);
const EXTENSIONES = new Set([
'.js', '.jsx', '.ts', '.tsx', '.json', '.yml', '.yaml',
'.env', '.properties', '.xml', '.conf', '.txt', '.md'
]);
const UMBRAL_ENTROPIA = 3.5;
const LONGITUD_MINIMA = 20;
const REGLAS = [
/(?:api[_-]?key|secret|token|password|passwd|pwd)\s*[:=]\s*["']?([A-Za-z0-9+/=_\-.]{16,})/gi,
/\b((?:sk|pk|ghp|gho|glpat|xox[abps])-[A-Za-z0-9-]{16,}|(?:AKIA|ASIA)[0-9A-Z]{16})\b/g
];
const FALSOS_POSITIVOS = /example|changeme|placeholder|your[_-]?key|<\w+>|\$\{/i;
function entropia(valor) {
if (!valor) return 0;
const conteo = new Map();
for (const caracter of valor) {
conteo.set(caracter, (conteo.get(caracter) ?? 0) + 1);
}
let resultado = 0;
for (const veces of conteo.values()) {
const probabilidad = veces / valor.length;
resultado -= probabilidad * Math.log2(probabilidad);
}
return resultado;
}
function enmascarar(valor) {
if (valor.length <= 8) return '*'.repeat(valor.length);
return `${valor.slice(0, 4)}${'*'.repeat(8)}${valor.slice(-4)}`;
}
async function* archivos(base = '.') {
for (const entrada of await readdir(base, { withFileTypes: true })) {
if (IGNORAR.has(entrada.name)) continue;
const ruta = join(base, entrada.name);
if (entrada.isDirectory()) {
yield* archivos(ruta);
} else if (EXTENSIONES.has(extname(entrada.name))) {
yield ruta;
}
}
}
const hallazgos = [];
for await (const archivo of archivos()) {
const contenido = await readFile(archivo, 'utf8');
contenido.split('\n').forEach((linea, numero) => {
for (const regla of REGLAS) {
regla.lastIndex = 0;
for (const coincidencia of linea.matchAll(regla)) {
const valor = coincidencia[1];
if (valor.length < LONGITUD_MINIMA) continue;
if (FALSOS_POSITIVOS.test(valor)) continue;
const puntuacion = entropia(valor);
if (puntuacion < UMBRAL_ENTROPIA) continue;
hallazgos.push({
archivo: relative('.', archivo),
linea: numero + 1,
puntuacion: puntuacion.toFixed(2),
valor: enmascarar(valor)
});
}
}
});
}
hallazgos.sort((a, b) => b.puntuacion - a.puntuacion);
for (const hallazgo of hallazgos) {
console.log(
`${hallazgo.archivo}:${hallazgo.linea} entropia=${hallazgo.puntuacion} ${hallazgo.valor}`
);
}
console.log(`\n${hallazgos.length} candidato(s) a secreto`);
La idea de fondo es sencilla: una contraseña escrita a mano tiene pocos símbolos distintos y su entropía es baja, mientras que un token generado aleatoriamente reparte sus caracteres y sube de 4 bits por carácter. El umbral de 3.5 funciona como primera aproximación.
Este script es únicamente ilustrativo. En un proyecto real conviene apoyarse en una herramienta que ya conoce los formatos de cada proveedor, gestiona el historial, permite auditar las excepciones y no tiene que mantener una lista de rutas ignoradas a mano.
Verificación activa: TruffleHog
Gitleaks dice "esto parece una credencial". TruffleHog puede dar un paso más: comprobar la credencial contra el servicio que dice emitirla y confirmar si sigue vigente.
# Solo hallazgos verificados contra el proveedor
trufflehog filesystem . --only-verified
# Historial completo de un repositorio
trufflehog git file://. --only-verified
# Resultado en JSON para integrarlo con otras herramientas
trufflehog git file://. --json > hallazgos.json
La diferencia práctica es importante: un patrón que coincide con el formato de una clave de AWS pero que en realidad es un identificador de ejemplo se descarta al no ser una credencial activa. A cambio, la verificación usa tráfico saliente, algo que conviene tener en cuenta en entornos sin acceso a internet.
Los secretos del frontend no son secretos
Este es el error de diseño más común y no lo arregla ninguna herramienta. Cualquier valor que se incruste en el bundle del navegador es público para quien abra las herramientas de desarrollo:
// Estas tres variables terminan dentro del bundle que descarga el navegador
const cliente = new PagosClient({
publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY,
secretKey: import.meta.env.VITE_SECRET_KEY
});
Las variables con prefijo público (VITE_, NEXT_PUBLIC_, REACT_APP_) se sustituyen por su valor durante la compilación. No hay cifrado posterior: el texto plano viaja dentro del archivo que el navegador descarga. Un escáner puede avisar si detecta una clave con ese formato, pero la solución es arquitectónica.
El patrón correcto es que el navegador solo conozca una clave pública y sin permisos, y que cualquier operación sensible se ejecute en un backend que es quien guarda la credencial:
// El navegador habla con tu servidor, nunca con el proveedor de pagos
const respuesta = await fetch('/api/pagos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monto: 1500, ordenId: 'A-1042' })
});
// src/server/pagos.ts
import { PagosClient } from '@pagos/sdk';
import { env } from '../config/env';
const cliente = new PagosClient({
secretKey: env.PAGOS_SECRET_KEY
});
export async function cobrar(monto: number, ordenId: string) {
return cliente.cobrar({ monto, ordenId });
}
Con esta separación, la credencial vive en variables de entorno del servidor, fuera del repositorio y fuera del bundle.
Errores frecuentes
Escanear solo el último commit. Es el error más común y el más peligroso: da la sensación de que el repositorio está limpio cuando el problema está en el historial. La opción de escaneo del historial completo es obligatoria.
Rotar la credencial sin limpiar el historial. La rotación invalida el valor filtrado, pero el objeto antiguo sigue en el historial de Git, en los clones y en los artefactos ya publicados.
Publicar la salida del escaneo sin --redact. El log del pipeline pasa a ser un nuevo almacén de credenciales, muchas veces con más permisos que el repositorio original.
Hacer la lista de excepciones demasiado amplia. Excepciones del tipo ignore everything that starts with sk- desactivan el detector sin avisar. Las excepciones deben ser concretas: una ruta, un patrón exacto o un valor de ejemplo conocido.
Confundir el escaneo con la rotación. Aunque no quede nada en el código, la credencial que estuvo expuesta debe considerarse comprometida.
Ignorar los artefactos. Un .env puede no estar en el repositorio y aun así terminar dentro de una imagen de Docker o de un paquete publicado, generado desde variables de entorno en el pipeline.
Conclusión
Secret scanning es una práctica de higiene, no una herramienta aislada. Sus puntos de apoyo son:
| Capa | Objetivo |
|---|---|
| Hook local | Que el secreto no llegue al commit |
| Integración continua | Revisar el historial completo, con salida redactada |
| Artefactos | Revisar lo que se publica, no solo el código fuente |
| Rotación | Tratar como comprometida cualquier credencial que estuvo expuesta |
Y hay un límite que conviene aceptar desde el principio: el escaneo detecta patrones, no intención. Ninguna herramienta sabe si una cadena es una credencial real, y por eso las herramientas que verifican contra el proveedor aportan información adicional. Aun así, la combinación de un hook rápido, un escaneo del historial en cada integración continua y una política de rotación inmediata reduce drásticamente la ventana de exposición, que es el único factor que de verdad importa cuando una credencial se filtra.
© Arturo Juárez Monroy