Auditoría de Seguridad Full-Stack para una Startup DeFi: De Atajos de Crecimiento a Seguridad Production-Grade

Centro de operaciones de seguridad con tres monitores mostrando alertas CRITICAL rojas, ícono de Ethereum con candado, y nodos de Kubernetes con warnings

Antes de correr un solo scan, le pedimos al equipo que nos explicara su pipeline de deploy. El lead backend engineer dijo: "Nos movemos rápido — todo está automatizado, las keys están en el repo para que las use el CI."

Esa frase contiene dos de las peores prácticas en seguridad cloud. Al final del primer día, entendimos la dimensión del trabajo por delante.

El engagement: una auditoría de seguridad full-stack para una startup DeFi — frontend React, backend Node.js, microservicios en EKS, un nodo Ethereum (Geth) en EC2, una base de usuarios moviendo activos crypto activamente. Corrimos la auditoría en paralelo sobre cada capa del stack.

Acá están todos los hallazgos y todos los fixes.

La Auditoría: Lo que Revelaron las Herramientas

Corrimos siete herramientas en paralelo sobre el codebase, la cuenta AWS y el cluster Kubernetes. Los hallazgos volvieron más rápido de lo que esperábamos.

Capa Herramienta Hallazgos Críticos
Historia de git TruffleHog 23 secretos (API keys, creds AWS, claves privadas ETH)
Código de aplicación Semgrep JWT alg:none, sin rate limiting, sin endpoint de logout
Contenedores + IaC Trivy + Checkov 14 CVEs de alta severidad en contenedores, 22 violaciones de políticas IaC
Cuenta AWS ScoutSuite EC2 en subnets públicas, security groups con 0.0.0.0/0, sin WAF
Kubernetes kube-bench 47 fallas del benchmark CIS
Ethereum Scripts personalizados RPC de Geth público, todos los métodos habilitados incluyendo personal_*
Bundle frontend Manual + DevTools API key de Alchemy + secreto de firma JWT hardcodeados en JS

Score de seguridad antes de cualquier cambio: 23/100.

Fase 1: La Catástrofe de los Secretos

El primer día, corriendo TruffleHog contra la historia completa de git — no solo la rama actual — devolvió 23 matches.

trufflehog git https://github.com/org/defi-platform \
  --only-verified \
  --json \
  | jq '.SourceMetadata.Data.Git | {commit, file, line}'

El desglose:

  • 9 pares de AWS access key / secret key (algunos con AdministratorAccess)
  • 6 API keys de Alchemy e Infura
  • 4 claves privadas de Ethereum para hot wallets
  • 3 secretos de firma JWT de servicios internos
  • 1 secret key de Stripe

Las claves privadas de Ethereum eran lo peor. Claves para hot wallets — cuentas con activos crypto reales — habían sido commiteadas 18 meses antes durante una "prueba rápida." Nunca squasheadas. Cualquiera que hubiera clonado el repo las tenía.

Paramos y rotamos los 23 secretos antes de continuar la auditoría: nuevas credenciales AWS, nuevas API keys, nuevos secretos de firma, nuevas direcciones de wallet con activos migrados. Recién entonces seguimos.

El Fix: git-secrets Pre-Commit + TruffleHog en CI

# Instalar git-secrets
git secrets --install
git secrets --register-aws

# Agregar patrones personalizados para claves privadas de Ethereum y secretos JWT
git secrets --add '0x[0-9a-fA-F]{64}'  # patrón de clave privada ETH
git secrets --add 'HS256|HS384|HS512'    # referencias a algoritmos de secreto JWT

# Bloquear commits con secretos detectados
git secrets --add-provider -- git secrets --list

Y en CI (GitHub Actions):

- name: TruffleHog Secret Scan
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: $
    head: HEAD
    extra_args: --only-verified --fail

--only-verified significa que TruffleHog solo falla el build si la credencial sigue activa — sin alert fatigue por secretos rotados en commits viejos. Tiempo medio para detectar un nuevo leak: menos de 5 minutos.

También migramos todos los secretos a AWS Secrets Manager con External Secrets Operator sincronizándolos hacia Kubernetes:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: defi-platform-secrets
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: defi-platform-secrets
    creationPolicy: Owner
  data:
    - secretKey: ALCHEMY_API_KEY
      remoteRef:
        key: defi-platform/production
        property: alchemy_api_key
    - secretKey: JWT_SIGNING_SECRET
      remoteRef:
        key: defi-platform/production
        property: jwt_signing_secret

Fase 2: Los Secretos del Frontend y el Desastre del JWT

Mientras el scan de git corría, abrimos Chrome DevTools en la app de producción y buscamos keywords conocidas en el bundle. Dos minutos después:

// Encontrado en main.chunk.js (línea 847, minificado pero sin ofuscar)
const ALCHEMY_KEY = "wss://eth-mainnet.alchemyapi.io/v2/AbC123XyZ..."
const JWT_SECRET  = "s3cr3t-pr0d-k3y-d0-n0t-sh4re"

La key de Alchemy exponía cuota de API y limitaba los datos on-chain. El secreto de firma JWT era peor: era la misma key que el backend usaba para verificar sesiones. Cualquiera que la extrajera podía forjar un token válido para cualquier user ID.

La implementación del JWT hacía esto todavía más peligroso — el backend nunca configuró restricciones de algoritmo:

// El código vulnerable (antes)
const decoded = jwt.verify(token, JWT_SECRET);
// Sin enforcement de algoritmo — acepta alg:none

No había endpoint /logout, no había claim exp. Un token emitido al crear la cuenta era válido indefinidamente.

El Fix: Enforcement de RS256, Expiración, Revocación, Logout

Migramos de HMAC a RS256. La clave privada firma; la clave pública verifica. El frontend nunca toca la clave privada:

// Enforce RS256 — rechazar cualquier otro algoritmo a nivel de biblioteca
const decoded = jwt.verify(token, PUBLIC_KEY, {
  algorithms: ['RS256'],  // whitelist only — alg:none lanza error inmediatamente
  issuer: 'defi-platform',
  audience: 'defi-platform-users',
});

Tiempos de vida del token:

// Access token: 15 minutos
const accessToken = jwt.sign(payload, PRIVATE_KEY, {
  algorithm: 'RS256',
  expiresIn: '15m',
});

// Refresh token: 7 días, almacenado en cookie HttpOnly
const refreshToken = jwt.sign({ userId: payload.userId }, PRIVATE_KEY, {
  algorithm: 'RS256',
  expiresIn: '7d',
});

Endpoint de logout con revocación respaldada por Redis:

app.post('/logout', authenticate, async (req, res) => {
  const { jti, exp } = req.user;  // jti = JWT ID, exp = timestamp de expiración
  const ttl = exp - Math.floor(Date.now() / 1000);

  // Agregar token ID a blocklist hasta que expire naturalmente
  await redis.set(`revoked:${jti}`, '1', 'EX', ttl);
  res.clearCookie('refresh_token');
  res.json({ success: true });
});

// En el middleware de autenticación
async function authenticate(req, res, next) {
  const token = extractToken(req);
  const decoded = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] });

  const isRevoked = await redis.get(`revoked:${decoded.jti}`);
  if (isRevoked) return res.status(401).json({ error: 'Token revoked' });

  req.user = decoded;
  next();
}

Fase 3: Sin Rate Limiting, Sin WAF, los Bots Eran Bienvenidos

El endpoint de creación de wallets aceptaba peticiones POST ilimitadas. Levantamos un loop de prueba simple:

for i in $(seq 1 1000); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST https://api.defi-platform.com/v1/wallets \
    -H "Content-Type: application/json" \
    -d '{"email": "test'$i'@example.com"}'
done
# Resultado: 1000x 200 OK. Cero throttling. Cero bloqueo.

Un actor malicioso podía crear miles de wallets programáticamente, farmear créditos de gas fees o agotar los recursos del backend. Los endpoints de transacciones tenían el mismo problema.

El Fix: AWS WAF + Rate Limiting con nginx

Desplegamos AWS WAF frente al ALB con bot management y reglas de rate limit personalizadas:

{
  "Name": "RateLimitPerIP",
  "Priority": 1,
  "Statement": {
    "RateBasedStatement": {
      "Limit": 100,
      "AggregateKeyType": "IP",
      "ScopeDownStatement": {
        "ByteMatchStatement": {
          "SearchString": "/api/",
          "FieldToMatch": { "UriPath": {} },
          "PositionalConstraint": "STARTS_WITH"
        }
      }
    }
  },
  "Action": { "Block": {} }
}

Y límites más estrictos en la capa de aplicación para endpoints de auth y wallets:

# Rate limiting de nginx para endpoints sensibles
limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=wallet:10m rate=5r/m;

location /api/v1/auth/ {
    limit_req zone=auth burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
}

location /api/v1/wallets {
    limit_req zone=wallet burst=2 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
}

Fase 4: Hardening de Kubernetes (47 Fallas CIS → 3)

kube-bench: 47 fallas CIS. La más crítica — el endpoint del EKS API server estaba vinculado a 0.0.0.0/0. Cualquiera en internet podía alcanzar el control plane de Kubernetes.

# Lo que encontramos
aws eks describe-cluster --name defi-cluster \
  --query 'cluster.resourcesVpcConfig'

# Output:
# "endpointPublicAccess": true,
# "publicAccessCidrs": ["0.0.0.0/0"],   <-- abierto al mundo
# "endpointPrivateAccess": false

Cerrando el API Server

aws eks update-cluster-config \
  --name defi-cluster \
  --resources-vpc-config \
    endpointPublicAccess=false,\
    endpointPrivateAccess=true

El acceso de CI/CD ahora enruta a través de AWS Client VPN. Ningún acceso externo al API server.

NetworkPolicies: Default Deny

# Default deny-all para cada namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
---
# Allow explícito: el backend puede alcanzar postgres
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-backend-to-postgres
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: postgres
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: backend
      ports:
        - protocol: TCP
          port: 5432

PodSecurityStandards (Perfil Restricted)

apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

También desplegamos Falco para monitoreo en runtime:

# Regla de Falco: alertar sobre shell spawns inesperados en pods de producción
- rule: Shell Spawned in Production Container
  desc: Se inició una shell dentro de un contenedor de producción
  condition: >
    spawned_process and
    container and
    k8s.ns.name = "production" and
    proc.name in (shell_binaries)
  output: >
    Shell iniciada en contenedor de producción
    (user=%user.name pod=%k8s.pod.name ns=%k8s.ns.name cmd=%proc.cmdline)
  priority: CRITICAL
  tags: [container, shell, production]

Después del hardening: 3 fallas CIS restantes — todas excepciones aceptables documentadas vinculadas a add-ons de EKS administrados que AWS controla directamente.

Fase 5: Rediseño del VPC y Aislamiento de EC2

Todas las instancias EC2 — incluyendo el nodo Ethereum — estaban en subnets públicas con IP pública. SSH estaba abierto desde 0.0.0.0/0. Sin bastion, sin VPN, sin SSM.

Rediseñamos el VPC desde cero:

Antes:
  Subnet pública ── EC2 (nodo Ethereum, IP pública, SSH abierto)
  Subnet pública ── EC2 (app servers, IP pública, SSH abierto)
  
Después:
  Subnet pública  ── Solo ALB
  Subnet privada  ── Worker nodes de EKS (NAT Gateway para salida)
  Subnet privada  ── EC2 (nodo Ethereum, sin IP pública)
  Subnet privada  ── RDS, ElastiCache

El cambio en Terraform para el nodo Ethereum:

resource "aws_instance" "ethereum_node" {
  ami                         = data.aws_ami.ubuntu.id
  instance_type               = "m5.2xlarge"
  subnet_id                   = aws_subnet.private_a.id   # era pública
  associate_public_ip_address = false                      # era true
  vpc_security_group_ids      = [aws_security_group.eth_node_private.id]

  iam_instance_profile = aws_iam_instance_profile.ssm_profile.name
}

# SSM reemplaza bastion + SSH
resource "aws_iam_instance_profile" "ssm_profile" {
  name = "eth-node-ssm-profile"
  role = aws_iam_role.ssm_role.name
}

SSH fue deshabilitado por completo. El acceso a todas las instancias EC2 ahora pasa por AWS Systems Manager Session Manager:

# Conectarse al nodo Ethereum — sin SSH key, sin puerto 22 abierto
aws ssm start-session --target i-0abc123def456

Fase 6: Bloquear el RPC de Geth

El endpoint RPC de Geth era alcanzable desde internet público en el puerto 8545 con todos los métodos habilitados — incluyendo personal_unlockAccount. Una llamada para desbloquear una cuenta, una llamada a eth_sendRawTransaction, y la hot wallet desaparecida.

Lo confirmamos con un probe:

curl -X POST http://[REDACTED]:8545 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"rpc_modules","params":[],"id":1}'

# Respuesta: {"result":{"admin":"1.0","debug":"1.0","eth":"1.0",
#            "miner":"1.0","net":"1.0","personal":"1.0","txpool":"1.0","web3":"1.0"}}
# personal, admin, miner — todos accesibles. Sin auth.

El Fix: Reverse Proxy nginx con Allowlist de Métodos

Colocamos un reverse proxy nginx frente a Geth con filtrado estricto de métodos. El nodo en sí se vincula solo a localhost:

# Inicio de Geth — solo localhost, sin binding público
geth \
  --http \
  --http.addr "127.0.0.1" \
  --http.port 8545 \
  --http.api "eth,net,web3" \
  --ws=false \
  --authrpc.jwtsecret /etc/geth/jwt.hex

nginx hace proxy solo de los métodos en la allowlist, agrega autenticación y rate-limiting:

upstream geth {
    server 127.0.0.1:8545;
}

limit_req_zone $binary_remote_addr zone=rpc:10m rate=50r/m;

server {
    listen 8546 ssl;
    ssl_certificate     /etc/ssl/geth.crt;
    ssl_certificate_key /etc/ssl/geth.key;

    location /rpc {
        limit_req zone=rpc burst=10 nodelay;

        # Bloquear métodos peligrosos
        if ($request_body ~* '"method"\s*:\s*"(personal_|admin_|miner_|debug_)') {
            return 403 '{"error":"method not allowed"}';
        }

        proxy_pass http://geth;
        proxy_set_header Authorization "Bearer $http_authorization";
    }
}

El frontend ahora usa Alchemy para todas las operaciones de lectura. El nodo Geth es interno únicamente para firma de transacciones.

El Scorecard Final

Hallazgo Antes Después
Secretos expuestos en git 23 0
Tiempo de detección de leak de secretos Nunca (manual) < 5 minutos (CI)
Fallas del benchmark CIS de K8s 47 3 (excepciones documentadas)
Instancias EC2 en subnets públicas Todas 0
Endpoint público del API server Sí (0.0.0.0/0) No (solo VPC)
Enforcement de algoritmo JWT Ninguno (alg:none aceptado) Solo RS256
Expiración del token Ninguna 15 min acceso / 7 días refresh
Endpoint de logout No Sí (con revocación Redis)
RPC de Geth público Sí (todos los métodos) No (localhost + proxy nginx)
Rate limiting en APIs Ninguno WAF + nginx (100/min general, 10/min auth)
Score de seguridad 23/100 89/100

Qué Haríamos Diferente

Correr TruffleHog contra la historia de git antes de onboardear cualquier codebase nuevo. Lo hicimos el primer día acá, pero hemos visto auditorías donde el escaneo de secretos solo corría contra el HEAD actual. Los secretos históricos son los más peligrosos porque tuvieron más tiempo para que alguien más los descubriera. Debería ser siempre el primer comando.

Empezar el rediseño del VPC desde un diagrama, no desde la consola. Rediseñamos este VPC en Terraform, pero primero esbozamos la arquitectura target en un pizarrón y se la presentamos al equipo antes de escribir una sola línea de HCL. Saltarse el pizarrón lleva a rediseños a medias donde algunos recursos quedan en subnets privadas y otros siguen siendo públicos porque nadie mapeó las dependencias. El diagrama también hace visible la mejora de seguridad para los no-ingenieros — los stakeholders entendieron la reducción de riesgo cuando pudieron ver el antes/después.

Para DeFi específicamente: traer un auditor de smart contracts para la capa on-chain. Esta auditoría cubrió la capa de infraestructura y aplicación exhaustivamente. Pero los contratos Solidity en sí estaban fuera de nuestro scope. Seguridad de infraestructura y seguridad de smart contracts son disciplinas diferentes. Una plataforma DeFi debería tratarlas como workstreams separados, no como una "revisión de seguridad" combinada. Señalamos varias interacciones entre el backend y los contratos que ameritaban una auditoría formal de smart contracts — ese trabajo fue scoped por separado y lo hizo una firma especialista.


¿Estás corriendo infraestructura blockchain o aplicaciones DeFi? La superficie de ataque es más grande que la de una app web tradicional — el manejo de claves privadas, la seguridad del RPC y las interacciones on-chain suman capas que los checklists de seguridad estándar pasan por alto. Hablemos y mapeamos tu exposición antes de que lo haga otro.

Preguntas frecuentes

¿Qué herramientas usaron para la auditoría de seguridad?

Siete herramientas en cuatro categorías: SAST — Semgrep para código de aplicación (reglas personalizadas para patrones Ethereum/Web3 más los rulesets estándar); escaneo de contenedores e IaC — Trivy para imágenes Docker y Terraform, Checkov para enforcement de políticas IaC; escaneo de secretos — TruffleHog sobre toda la historia de git (no solo el último commit); cloud e infraestructura — ScoutSuite para la auditoría de la cuenta AWS, kube-bench para el benchmark CIS de Kubernetes; Ethereum específico — scripts Python personalizados para sondear el endpoint RPC de Geth y enumerar los métodos disponibles. Ninguna herramienta sola encuentra todo. Necesitás las cuatro categorías trabajando juntas.

¿Qué tan peligrosa es la vulnerabilidad JWT alg:none en la práctica?

Catastrófica si el backend maneja algo valioso. El ataque es trivial: tomás un JWT válido, cambiás el campo alg del header a none, eliminás la firma y lo enviás. Un backend vulnerable lo acepta como legítimo. En este caso, la app controlaba wallets DeFi y firma de transacciones — un atacante que forjara un token para cualquier user ID podía iniciar transacciones en su nombre. El fix es estricto: enforceás un algoritmo específico (RS256), rechazás cualquier otro a nivel de biblioteca, y nunca confiás en el campo de algoritmo del token en sí.

¿Por qué es tan peligroso un endpoint RPC de Geth público?

Un endpoint RPC de Geth completamente abierto es esencialmente un control remoto sin autenticación para tu nodo Ethereum. Con personal_unlockAccount disponible, un atacante puede desbloquear cualquier cuenta que administre el nodo y luego llamar a eth_sendRawTransaction para mover fondos. Incluso sin desbloquear cuentas, un endpoint público filtra el historial completo de transacciones, datos del mempool e información de peers conectados. La postura correcta es: ningún RPC público. El frontend usa un proveedor de terceros (Alchemy/Infura) para operaciones de lectura. La firma interna de transacciones pasa por un servicio de firma dedicado con integración de hardware wallet, nunca a través de una instancia de Geth directamente expuesta.

¿Cómo se previene que los secretos lleguen a git en primer lugar?

Dos capas: pre-commit hooks que ejecutan TruffleHog localmente antes de que se complete cualquier commit (gitleaks también funciona), y escaneo en el pipeline de CI que repite el mismo chequeo en cada push. Los pre-commit hooks se pueden bypassear con --no-verify, razón por la cual el gate de CI importa — es obligatorio y bloquea el PR. Usamos TruffleHog en modo verified para CI, que chequea si las credenciales filtradas siguen activas, no solo si el patrón de string coincide. Secreto activo detectado en CI = PR bloqueado, alerta de Slack disparada, rotación empieza en minutos.

¿Cuál es la arquitectura correcta para el manejo de claves privadas de Ethereum?

Las claves de hot wallet nunca deben tocar el código de la aplicación, variables de entorno en EC2, ni Kubernetes secrets sin protección adicional. La arquitectura correcta: (1) Usás AWS KMS para el cifrado de claves en reposo — el plaintext nunca sale de KMS; (2) Para operaciones de firma, usás un servicio de firma dedicado (p.ej., AWS Nitro Enclaves o un plugin de HashiCorp Vault) que acepta payloads de transacciones y devuelve firmas sin exponer jamás la clave privada; (3) Para wallets de alto valor, usás una hardware wallet (Ledger/Trezor) conectada a una máquina de firma air-gapped para cold storage. El frontend nunca debería tener capacidad de firma — construye transacciones, la wallet propia del usuario (MetaMask) las firma.

¿Cómo se endurece el endpoint del API server de K8s en EKS?

Tres pasos: (1) Configurás endpointPublicAccess: false y endpointPrivateAccess: true en la config del cluster EKS — esto hace que el API server solo sea alcanzable desde dentro del VPC; (2) Si necesitás acceso externo para CI/CD, conectás via VPN o AWS Client VPN, nunca whitelist un CIDR amplio; (3) Habilitás EKS audit logging hacia CloudWatch y alertás sobre patrones de API anómalos (p.ej., exec en pods de producción, llamadas get secrets desde service accounts inesperados). kube-bench debería ejecutarse después de cualquier cambio para verificar que no se introdujeron nuevas fallas CIS.