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.