Secret Scanning: cómo evitar que las credenciales terminen en el repositorio

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 secretoAspecto típico del valor
Claves de APIPrefijos reconocibles como sk-, pk-, ghp_, AKIA
Tokens de accesoCadenas aleatorias de 32 o más caracteres
ContraseñasAsignaciones del tipo password=, PWD:, secret:
Cadenas de conexiónJDBC, MongoDB o URLs con usuario y contraseña incrustados
Claves privadasBloques BEGIN ... PRIVATE KEY
Tokens de serviciosBearer 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:

SuperficieCuándoQué encuentra
Espacio de trabajoAntes de commitear, con un hookLo que acabas de escribir
Repositorio completoEn integración continua, sobre todo el historialSecretos de hace dos años
ArtefactosAl publicar paquetes, imágenes o buildsArchivos .env y .map embebidos
Aplicación desplegadaEn la aplicación, sobre lo que se sirveClaves 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: 0 descarga el historial completo. Con el historial recortado, el escaneo pasa medio repositorio por delante sin avisar.
  • El paso de escaneo debe usar --redact y, 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:

CapaObjetivo
Hook localQue el secreto no llegue al commit
Integración continuaRevisar el historial completo, con salida redactada
ArtefactosRevisar lo que se publica, no solo el código fuente
RotaciónTratar 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