Objetivo: construir un módulo de observabilidad de seguridad de nivel profesional que registre, agregue y visualice la actividad hostil contra codebymike.tech (endpoints sondeados por atacantes, patrones de ataque, rate limiting, anomalías de comportamiento), con alertas en tiempo real, SLOs de seguridad, y una vitrina pública (
/security+ tarjetas en/statusy/tools) que demuestre el nivel técnico sin filtrar información sensible.Nombres técnicos del dominio (para hablar con propiedad en la vitrina y entrevistas): Security Observability / Attack Surface Monitoring, con piezas de WAF (Web Application Firewall), IDS ligero (Intrusion Detection), honeypots HTTP, threat intelligence básica y anomaly detection. En la industria esto vive en un SIEM (Security Information & Event Management); aquí construimos un "micro-SIEM" propio.
Decisiones: prioridad a desarrollo propio; terceros solo en capa free y sin acoplarse (mismo patrón no-op de
notify.ts). Todo corre dentro del proyecto (Astro + Turso).
| Área | Estado |
|---|---|
| Middleware | src/middleware.ts: auth de /admin, headers de seguridad (HSTS, CSP en admin), chaos LAB, registro de sesiones de dispositivo |
| Rate limiting | src/lib/ratelimit.ts: ventana fija en memoria, por instancia — se pierde entre cold starts y no comparte estado entre instancias. Usado solo en contact, checkout, mock/pay |
| Registro de actividad hostil | Cero. Los 404 de scanners (p. ej. /wp-login.php, /.env) no se registran en ninguna parte |
| Firewall de plataforma | Vercel plan Hobby: DDoS mitigation automática incluida; WAF con 3 custom rules gratis y challenge/deny sin costo — disponible pero sin configurar |
| Observabilidad existente | Motor propio de uptime (monitors.ts), SLO (slo.ts), Web Vitals, ntfy + Resend (notify.ts), /status público |
| Auth/sesiones | Auth.js + allowlist GitHub, tabla admin_sessions con revocación, IP y user-agent ya capturados |
| Base de datos | Turso/libSQL + Drizzle, migraciones aditivas con drizzle-kit generate |
Ventaja clave: ya existe el patrón completo a replicar — tabla de eventos + cron de agregación + panel admin + página pública filtrada + alertas ntfy. El módulo de seguridad es estructuralmente análogo al de monitores.
┌─────────────────────────────────────────────┐
request entrante → │ Vercel (DDoS mitigation + WAF 3 reglas free)│ capa 0: plataforma
└───────────────────┬─────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ src/middleware.ts │ capa 1: detección
│ · clasificador de amenazas (propio) │
│ · rate limiter durable (Turso, fail-open) │
│ · bloqueo por IP (lista en Turso, cache) │
└───────────────────┬─────────────────────────┘
▼ (async, no bloquea la respuesta)
┌─────────────────────────────────────────────┐
│ security_events (Turso) │ capa 2: registro
│ 404.astro + endpoints honeypot también │
│ escriben aquí │
└───────────────────┬─────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ cron /api/cron/security-rollup │ capa 3: análisis
│ · agregados horarios/diarios │
│ · detección de anomalías (z-score sobre │
│ baseline de 30 días) │
│ · auto-block de IPs reincidentes │
│ · alertas ntfy/email si severidad ≥ alta │
└───────────────────┬─────────────────────────┘
▼ ▼
/admin/security (privado) /security (vitrina pública)
Principios (mismos del LAB, no negociables):
- Fail-open absoluto: ningún fallo del pipeline de seguridad puede tumbar o
ralentizar el sitio. Toda escritura de eventos es fire-and-forget con
catchsilencioso; toda lectura de listas de bloqueo tiene cache en memoria con TTL y fallback a "permitir". - Presupuesto de latencia: la capa 1 añade ≤ 5 ms p99 al request (una lectura
cacheada de la blocklist cada ~30 s; la clasificación es regex/lookup en memoria; la
escritura del evento no se espera con
awaiten el camino de respuesta —context.waitUntilsi está disponible, o promesa suelta con catch). - Aditivo: tablas nuevas, rutas nuevas, un solo punto de contacto con código
existente (el middleware, extendido con el mismo cuidado que
maybeChaos). - Privacidad y OPSEC de la vitrina: la página pública muestra agregados y
tendencias, jamás IPs completas (se muestran enmascaradas
181.xx.xx.xxo hasheadas), ni rutas internas reales del admin, ni reglas de detección exactas (no darle el playbook al atacante). - Retención: eventos crudos 90 días, agregados horarios 13 meses, diarios indefinido. Purga en el mismo cron. Turso free (5 GB) sobra: un evento pesa ~300 bytes; incluso 50k eventos/mes son ~15 MB/año.
security_events — evento crudo por request sospechoso
id, at (unixepoch), ip (texto), ipHash (sha-256 truncado, para públicos),
method, path, query (truncado 200 chars), userAgent (truncado 300),
country (header x-vercel-ip-country), asn (x-vercel-ip-as-number, si llega),
category (enum abajo), severity ('low'|'medium'|'high'|'critical'),
action ('logged'|'rate_limited'|'blocked'|'honeypot'), statusCode,
ruleId (qué regla del clasificador disparó)
security_rollups — agregado horario/diario para dashboards y baseline
id, bucket ('hour'|'day'), at, category, count, uniqueIps, topPath, topCountry
blocked_ips — lista de bloqueo con TTL (nunca bloqueos eternos por defecto)
ip (pk), reason, ruleId, hits, createdAt, expiresAt (obligatorio; escalado:
1h → 24h → 7d por reincidencia), source ('auto'|'manual')
rate_limit_buckets — estado durable del rate limiter (clave, ventana, contador)
key (pk), count, resetAt — con purga perezosa en el cron
security_anomalies — hallazgos del detector (para timeline y alertas)
id, at, kind ('spike'|'new_pattern'|'geo_anomaly'|'auth_probing'|'error_burst'),
zScore, baseline, observed, detail (json), notified, acknowledged
Alineada con OWASP Top 10 para que la vitrina hable el idioma de la industria:
| Categoría | Detecta | Ejemplos de firmas |
|---|---|---|
recon_cms |
Scanners de CMS/paneles ajenos | /wp-login.php, /wp-admin, /xmlrpc.php, /administrator, /phpmyadmin |
secrets_probing |
Búsqueda de secretos/config | /.env*, /.git/*, /config.json, /.aws/credentials, /id_rsa, /backup.sql |
path_traversal |
LFI/traversal (OWASP A01/A03) | ../, %2e%2e, /etc/passwd, null bytes |
injection |
SQLi/XSS/cmd en query o path (A03) | union select, <script, ' or 1=1, ;wget, ${jndi: |
auth_probing |
Fuerza sobre auth propia | ráfagas a /api/auth/*, /login, callbacks manipulados |
bad_bot |
UAs de herramientas ofensivas | sqlmap, nikto, nuclei, masscan, zgrab, UA vacío en rutas API |
api_abuse |
Rate limit excedido en APIs públicas | contact, checkout, vitals |
honeypot |
Tocó un endpoint trampa | ver honeypots abajo |
protocol_anomaly |
Métodos raros, hosts falsos | TRACE, CONNECT, Host header spoofing |
El clasificador vive en src/lib/security/classify.ts: tabla de reglas puras
(regex precompiladas + listas), 100% testeable con Vitest (esto alimenta la Fase 5
del LAB: la suite de seguridad suma coverage real).
- Migración con las 5 tablas.
src/lib/security/classify.ts+src/lib/security/events.ts(recordSecurityEvent, fire-and-forget, dedupe en memoria de ráfagas idénticas: máx 1 escritura/seg porip+ruleIdpara que un scan de 500 rutas no haga 500 inserts — se acumulahits).- Hook en
middleware.ts: clasificar el request; si matchea, registrar. Sin bloquear todavía (fase de solo observación — igual que se despliega un WAF real: primerolog, luegoenforce). El middleware ve TODAS las rutas (incluidas las que acaban en 404), así que un scanner de/wp-login.php,/.env, etc. ya queda registrado aquí — no hace falta un hook aparte en404.astro(evita doble conteo). Un hook de 404 solo añadiría valor para medir volumen de escaneo de rutas SIN firma; se deja para una fase posterior si interesa esa métrica. - Tests Vitest del clasificador y de la redacción (tabla de casos: ruta → categoría/ severidad esperada; IP → hash/máscara).
Criterio de salida: eventos reales acumulándose en prod durante ≥ 72 h para tener datos antes de encender el enforcement (evita bloquear tráfico legítimo por una regla mal calibrada).
Entregado: ratelimit-durable.ts (dos capas memoria→Turso, upsert atómico, fail-open
150 ms), blocklist.ts (cache 30 s + allowlist + escalado de TTL 1h→24h→7d),
paths.ts (helpers de rutas), enforcement en middleware.ts (blocklist 403 + auth
limiter 30/min + paraguas global 600/min), y los 3 endpoints migrados a enforceLimit.
ratelimit.ts recortado a solo clientIp. Tests: security-ratelimit.test.ts +
security-blocklist.test.ts. Verificado e2e: IP bloqueada→403, IP normal→200, 6º POST a
/api/contact→429. Nota: los límites reales quedaron algo más altos que el borrador de
abajo (auth 30/min, global 600/min) para no rozar a usuarios reales; se recalibran con
datos. El auto-block (que llena blocked_ips) es de la Fase 2.
src/lib/security/ratelimit-durable.ts: sliding window sobrerate_limit_buckets(unINSERT ... ON CONFLICTatómico por check), con cache de primer nivel en memoria para no ir a Turso en cada request: solo consulta la DB cuando el contador local se acerca al límite. Fail-open si Turso no responde en 150 ms.- Política por defecto (documentada en el plan y en la vitrina):
- APIs públicas de escritura (
contact,checkout): 5 req/min/IP, durable. api/auth/*: 10 req/min/IP → al exceder, eventoauth_probingseveridad alta.- Global por IP: 300 req/min (paraguas anti-scraping agresivo).
- APIs públicas de escritura (
- Enforcement de blocklist en middleware: cache en memoria (TTL 30 s) de
blocked_ipsvigentes; IP bloqueada →403+ eventoaction='blocked'. Respuesta mínima, sin pistas. - Migrar los 3 usos actuales de
ratelimit.tsal nuevo módulo (manteniendo la versión en memoria como primera capa barata).
SLO propio del limiter: overhead p99 ≤ 5 ms, tasa de falsos positivos objetivo
< 0.1% de requests legítimos (medible: eventos rate_limited cuya IP luego navega
normalmente).
Entregado: rutas señuelo /wp-login.php, /admin.php, /api/v1/token
(src/pages/…, exportan ALL) que sirven contenido falso plausible tras un tarpit
acotado 800–2000 ms (src/lib/security/honeypot.ts); no re-registran (el middleware ya
lo hace vía HONEYPOT_PATHS). src/lib/security/autoblock.ts (selectIpsToBlock puro +
runAutoBlock): honeypot→bloqueo inmediato, ráfaga high/critical ≥ umbral→bloqueo, con
allowlist, tope de 500 y escalado de TTL. Cron src/pages/api/cron/security-rollup.ts
(GET Bearer + PUT admin): auto-block + purga de bloqueos/buckets vencidos + eventos > 90 d
- alerta push si overflow; registrado en
vercel.json(0 * * * *).robots.txtcon Disallow de señuelos. Tests:security-autoblock+security-honeypot(255 totales verdes). Verificado e2e: honeypot→200/401 falso, cron bloquea la IP (source='auto', TTL 3600 s), request posterior→403. Pendiente operativo: dar de alta el cron en cron-job.org.
Refuerzo (2026-07-19) — bloqueo inline de honeypots: el auto-block dependía
únicamente del cron, que nunca se dio de alta en cron-job.org y además se removió de
vercel.json (posible límite de crons del plan) → los honeypots tocados no se bloqueaban
en la práctica: se veían hits repetidos de la misma IP en /admin/security sin bloqueo.
Como un hit a ruta señuelo es intención inequívoca (cero falsos positivos), se movió ese
caso a bloqueo inline en src/middleware.ts: observeRequest ya devuelve la
clasificación de forma síncrona; si category==='honeypot', el middleware llama a
blockIpEscalated justo tras el check de blocklist. El request que dispara la trampa
sigue su curso y recibe el señuelo (tarpit + HTML falso — no se delata la trampa en el
primer contacto); a partir del siguiente request la IP cae en la blocklist (403). Se
extrajo blockIpEscalated en src/lib/security/blocklist.ts (lee hits previos →
escalatedTtlSec → blockIp), compartido por el inline y por runAutoBlock (un único
punto de escalado, sin divergencia). El cron sigue siendo backstop para la ráfaga
high/critical ≥ umbral, la purga de bloqueos vencidos y la detección de anomalías → dar de
alta GET /api/cron/security-rollup (Bearer CRON_SECRET) en cron-job.org, cada ~15 min.
Test nuevo con libSQL temporal: tests/security-blocklist-db.test.ts (escalado real sobre
el onConflictDoUpdate de blocked_ips + veto vía isBlocked). Fail-open en todo el
camino inline: si el insert falla, el request continúa.
- Endpoints trampa que ningún usuario legítimo toca:
/wp-login.php,/.env,/admin.php,/api/v1/token(rutas Astro reales que responden 200 con contenido plausible-pero-falso tras un delay aleatorio de 1–3 s — un tarpit suave — y registranhoneypot/critical). AñadirDisallowde esas rutas enrobots.txt(los crawlers legítimos las respetan; los atacantes no — filtro adicional). - Auto-block escalonado. El caso
honeypotse bloquea inline en el middleware (ver refuerzo 2026-07-19 arriba); la ráfaga de ≥ N eventoshighen 10 min sigue corriendo en el cron. Ambos → insert enblocked_ipscon TTL 1 h; reincidencia → 24 h → 7 días. Salvaguardas: nunca auto-bloquear IPs de rangos de Vercel/cron-job.org ni la IP del admin (allowlist enapp_settings); tope de 500 IPs bloqueadas simultáneas (si se excede, alerta en vez de bloquear — señal de ataque distribuido que se maneja en capa 0). - Panel de gestión manual: bloquear/desbloquear desde
/admin/security.
Entregado: src/lib/security/anomaly.ts (puro: mean/stddev/zScore + detectSpikes
z>3 con mínimo absoluto, detectNewPatterns, detectGeoAnomalies), rollup.ts
(aggregateByCategory puro + storeRollups horario/diario idempotente + baselines
acotadas por hora-del-día, excluyendo la hora evaluada), anomaly-store.ts (persistencia
con anti-fatiga: dedup por kind mientras haya una abierta < 24 h). El cron
security-rollup ahora encadena auto-block → rollups → detección+persistencia → purga →
alertas (push + email agrupado). Tests: security-anomaly + security-rollup (274
totales verdes). Verificado e2e: baseline de 15 días + spike de 40 → 2 anomalías (spike
z=47.6 sobre baseline 1.93, y new_pattern de /search), rollups horario/diario escritos,
2º disparo → 0 anomalías (anti-fatiga). Nota: se corrigió un off-by-one para que la hora
evaluada no entre en su propia baseline.
/api/cron/security-rollup(vercel.json, cada hora + respaldo cron-job.org como los monitores): agrega la hora anterior asecurity_rollups, ejecuta auto-block, purga retención, actualiza baseline.- Detección de anomalías (propia, estadística simple y explicable — vende más en
una sustentación que una caja negra):
- Spike: eventos/hora por categoría vs media+desviación de la misma hora en los últimos 30 días; z-score > 3 → anomalía.
- New pattern:
ruleIdgenérico con path nunca visto que se repite ≥ 10 veces. - Geo anomaly: país nuevo entrando al top-3 de origen de eventos high/critical.
- Auth probing: > X fallos de auth en la ventana.
- Error burst: correlación con
monitor_checks(¿el ataque coincide con degradación de uptime? — esto une los dos sistemas de observabilidad).
- Alertas vía
notify.tsexistente:criticalinmediato (push prioridad 5),highagrupado por hora, resumen diario por email. Anti-fatiga: una anomalía abierta no re-alerta hasta que se reconozca o pasen 24 h.
Entregado: página única src/pages/admin/security.astro (dashboard + explorador +
blocklist en una vista, en vez de 4 sub-rutas) con KPIs (eventos 24h/7d, IPs bloqueadas,
críticos), barras de categorías/países/rutas más atacadas (24h), anomalías abiertas con
botón "Reconocer", tabla de IPs bloqueadas con desbloqueo y form de bloqueo manual, y
explorador de eventos (7d) con filtros por categoría/severidad (query params, server-side).
API de mutación src/pages/api/admin/security.ts (POST block/unblock/ack, protegida
por el guard de /api/admin). Entrada "Seguridad" añadida al grupo Sistema del
Sidebar.astro. Verificado: queries drizzle ejecutadas contra Turso (test de integración
temporal, ya borrado), guard redirige 302 sin sesión tanto la página como la API. UI reusa
los patrones de sessions.astro/monitors.astro (glass cards, fetch POST, rel-time).
Nueva entrada en el sidebar (grupo Sistema, junto a Monitores):
/admin/security → Dashboard: eventos 24h/7d, top categorías, top países,
top paths atacados, IPs bloqueadas activas, anomalías abiertas
/admin/security/events → Explorador con filtros (categoría, severidad, IP, rango)
/admin/security/blocklist → Gestión de bloqueos (manual + auto, con TTL visible)
/admin/security/rules → Estado del clasificador: hits por regla, últimos matches
(para calibrar reglas ruidosas)
Gráficas con el mismo enfoque server-rendered de /status (SVG/tablas, sin librerías
cliente pesadas). Acciones de mutación con el mismo guard auth + CSRF de las APIs admin.
Entregado: sección "Security Operations" añadida a /security (existente, no
reemplazada — ya tenía hallazgos reales de auditoría OWASP con commits, que se
conservaron intactos): KPIs (detectados 30d, bloqueos automáticos, categorías OWASP,
overhead objetivo), desglose por categoría, origen geográfico, tendencia diaria (14d,
barras SVG-less con CSS), diagrama de 4 capas y SLOs publicados como objetivos de
diseño (no se afirma falsamente que son medidos en producción). Todas las queries
agregadas, sin IPs crudas ni nombres de reglas ni lista de honeypots — verificado
grepeando el HTML servido. Tarjeta de enlace añadida en /status. Reajuste de
OPSEC vs. el borrador original: se cambió "top 5 rutas señuelo más atacadas" por
"categoría más atacada" — listar rutas señuelo específicas contradice el principio de
no revelar cuáles endpoints son honeypots. Bug encontrado y corregido en verificación
visual: las barras de tendencia no renderizaban por falta de h-full en el contenedor
flex (porcentaje de altura sin base de referencia). Pendiente: caso de estudio en
/tools y artículo en /notes (contenido, no bloqueante — se puede añadir después).
/security(rediseño de la página existente) → "Security Operations":- Contadores agregados: "N intentos de intrusión detectados y bloqueados este mes", desglose por categoría OWASP, top 5 rutas-señuelo más atacadas, mapa/lista de países de origen, tendencia de 90 días.
- Sección "Cómo funciona": diagrama de las 4 capas, presupuesto de latencia, política de retención, filosofía fail-open — el texto técnico es la pieza de marketing.
- SLA/SLO publicados: overhead del pipeline, tiempo de detección→bloqueo (objetivo: auto-block en ≤ 60 min vía cron, inmediato para rate limit), falsos positivos.
- OPSEC: solo agregados; IPs enmascaradas; sin nombres de reglas exactos; sin revelar cuáles endpoints son honeypots (decir "endpoints señuelo" sin listarlos).
- Tarjeta "Security" en
/status(junto a uptime) y entrada en/tools. - Artículo en
/notes: "Construyendo un micro-SIEM para mi portfolio" — el formato que ya usas para mostrar conocimiento. - OG image propia, identidad CodeByMike.
Corrección al borrador: la auditoría mostró que la CSP ya corría en modo enforce
(no report-only) tanto en /admin como en público desde antes de esta fase — el punto 2
original ("CSP solo cubre admin") estaba desactualizado. Por eso NO se hizo la migración
report-only→enforce (ya estaba en enforce); en su lugar se añadió observabilidad
continua sobre una CSP que ya bloquea.
Entregado (código, ya committeado):
src/lib/security/csp-report.ts(parser puro de los dos formatos de reporte: legacyapplication/csp-reporty Reporting APIapplication/reports+json) + endpointsrc/pages/api/security/csp-report.ts(POST sin auth, con rate limit durable 20/min/IP, registracategory='csp_violation'víarecordSecurityEvent). Middleware: headerReporting-Endpoints+ directivasreport-to/report-uriañadidas a la CSP existente (pública y admin) — el navegador ya bloqueaba, ahora además avisa.Permissions-Policyglobal (camera/microphone/geolocation/payment/usb/interest-cohort/ browsing-topics todos en()), en ambas ramas del middleware./.well-known/security.txt(RFC 9116): ya existía, completo y correcto (Contact, Expires, Preferred-Languages, Canonical, Policy) — no se modificó.- Tests:
security-csp-report.test.ts(280 totales verdes). Verificado e2e: headers confirmados por curl en/(CSP con report-to/report-uri, Permissions-Policy, Reporting-Endpoints), POST de prueba al endpoint → 204 + eventocsp_violationregistrado con eldocument-uriyblocked-uricorrectos. Datos de prueba limpiados.
Pendiente — acción manual en el dashboard de Vercel (no ejecutable desde el agente): las 3 custom rules gratis del WAF. Con el motor propio ya cubriendo detección/bloqueo, estas reglas son un respaldo de plataforma (capa 0) para cuando el propio origen esté sobrecargado o el ataque sea volumétrico. Sugerencia concreta a configurar en Vercel → Project → Firewall:
- Deny a paths que matcheen patrones de CMS/secrets (
/wp-*,/.git/*,/.env*) — con excepción explícita de las rutas propias/wp-login.php,/admin.php,/api/v1/token(son honeypots reales del proyecto, deben seguir respondiendo). - Challenge (o Deny) a User-Agents que contengan
sqlmap|nikto|nuclei|masscan|nmap(mismo patrón queclassify.ts, como defensa redundante antes de que el request llegue a la función). - Rate limit de respaldo en
/api/*(p. ej. 300 req/10s por IP) — red de seguridad por si el limiter durable propio fallara (fail-open) bajo un ataque muy agresivo.
| Métrica | Objetivo | Medición |
|---|---|---|
| Overhead del pipeline (p99) | ≤ 5 ms | timestamps en middleware, muestreado 1% |
| Tiempo detección → bloqueo (auto) | ≤ 60 min | blocked_ips.createdAt − primer evento |
| Tiempo detección → alerta (critical) | ≤ 60 s | evento → push ntfy |
| Falsos positivos de bloqueo | < 0.1% | revisión de desbloqueos manuales |
| Disponibilidad del sitio bajo scan activo | SLO 99.5% existente sin degradar | correlación con monitor_checks |
| Cobertura de tests del clasificador | ≥ 95% líneas | Vitest coverage (suma al LAB) |
| Riesgo | Mitigación |
|---|---|
| Regla mal calibrada bloquea usuarios reales | Fase 0 en modo solo-log 72 h; TTL obligatorio en bloqueos; allowlist; página /admin/security/rules para ver ruido por regla |
| Escrituras a Turso amplifican un ataque (el ataque genera writes) | Dedupe 1 write/s por ip+regla; tope de eventos/min global con degradación a solo-contador en memoria |
| Auto-block se vuelve arma de DoS contra terceros (IP spoofing en XFF) | En Vercel x-forwarded-for lo pone la plataforma (no falsificable en la primera IP); aún así, TTLs cortos y tope de 500 IPs |
| Vitrina filtra inteligencia al atacante | Reglas de OPSEC de la Fase 5 (solo agregados, sin firmas, sin lista de honeypots) |
| Cron de Vercel Hobby limitado (1 ejecución/día por cron en Hobby) | Igual que los monitores: cron-job.org (free) como disparador horario del endpoint, protegido por token — patrón ya probado en el repo |
| Crecimiento de datos | Retención por capas + purga en cron; estimación 15 MB/año vs 5 GB free |
- Vercel WAF free (3 reglas + DDoS automático) — capa 0, ya incluido en el plan Hobby.
- cron-job.org — disparador horario (ya en uso para monitores).
- ntfy.sh + Resend — alertas (ya integrados vía
notify.ts). - Opcional futuro, no en el MVP: enriquecimiento de IPs con listas públicas gratuitas
(p. ej. feed de AbuseIPDB free o listas de Tor exit nodes descargadas por el cron y
cacheadas en
app_settings) para marcar "IP con reputación conocida" en el panel.
Fase 0 (sensor) → 72 h de datos → Fase 1 (limiter) → Fase 2 (honeypots/auto-block)
→ Fase 3 (cron/anomalías/alertas) → Fase 4 (panel) → Fase 5 (vitrina) → Fase 6 (WAF/CSP).
Total: ~7–9 sesiones de trabajo. Las fases 0–3 son backend puro y se pueden verificar
con curl + Vitest; las 4–5 reutilizan patrones de UI ya existentes (monitores/status).