← Back to home

What's new

The latest additions and improvements in CyberEscudo: new tools and features, blog articles and platform changes (detailed entries are kept in Spanish). The full technical detail (tests, performance, accessibility…) is further down and on GitHub.

✨ New

Fase 16: rutas con certificado verificable

✨ New

Fase 15: El Turno diario

✨ New

Fase 14: API pública del auditor de correo

✨ New

Fase 13: guías de SPF, DKIM y DMARC por proveedor

✨ New

Fase 11: boletín semanal

✨ New

Fase 12: auditor de correo v2 (extras que no cuentan en la nota)

✨ New

Fase 10: «Vigila tu dominio» y analizador de informes DMARC

✨ New

Fase 9: Observatorio de suplantación por correo

✨ New

Fase 8: bloque «Para medios» en el informe del IBEX 35

✨ New

Fase 7: informe «¿Cuántas empresas del IBEX 35 se pueden suplantar por correo?»

✨ New

Fase 6: el auditor de correo como herramienta estrella

✨ New

Sección «Productos propios» en la portada y en el menú

✨ New

Medición del CTA «Hablemos»: la conversión que el tráfico no dice

✨ New

HandMetric en «Proyectos Propios», marcado como próximamente

✨ New

Enlace a Secure AI Kit, el otro proyecto

✨ New

El panel compara periodos, y el gráfico deja de mentir

✨ New

Enlace de vuelta al sitio desde el panel

✨ New

Las credenciales de la BD pueden ir en secrets.php, fuera del web root

✨ New

Oferta de ayuda tras una auditoría (solo cuando hay algo que arreglar)

✨ New

Contador de visitas propio, sin cookies y sin datos personales

✨ New

Expedientes técnicos reproducibles (/expedientes) — Fase 5

✨ New

Recruiter Challenge (/recruiter-challenge): «Test My Skills» interactivo

✨ New

Incidente de la semana: 3 casos nuevos + programación semanal

✨ New

CyberEscudo Passport (/u/sergio): la tarjeta pública compartible

✨ New

Incidente de la semana: motor de contenido «What would you do?»

✨ New

Aprende por temas: hubs de contenido (Fase 4)

✨ New

Security Pulse: cada CVE cruza a ATT&CK y al arsenal (Fase 3, parte 2)

✨ New

Cross-link ATT&CK bidireccional: cada herramienta enseña su técnica (Fase 3)

✨ New

Incidente del día: un reto de SOC nuevo cada día (Fase 2)

✨ New

Recruiter Mode (/recruiter) — la vista de 60 segundos

✨ New

Proof of Work (/proof) — la evidencia por habilidad

✨ New

WordPress vuln checker · Fases 2-3: ETL + endpoint + UI

✨ New

WordPress vuln checker · Fase 1: motor de matching

✨ New

Auditor de configuración (sshd_config, .htaccess, wp-config.php)

✨ New

Flagship «El viaje de una consulta DNS, paso a paso»

✨ New

Flagship «Login con Google (OAuth 2.0), paso a paso»

✨ New

Flagship «El handshake TLS, paso a paso»

✨ New

Threat Radar: tipo de fallo (CWE) en el análisis

✨ New

Threat Radar: ficha por CVE con URL propia (SEO)

✨ New

Threat Radar: análisis determinista por CVE (sin IA)

✨ New

SOC Range: dos casos reales más (ProxyLogon, RDP-ransomware)

✨ New

SOC Range «El Turno»: Fase 4 — «Revive una brecha famosa»

✨ New

SOC Range «El Turno»: Fase 3 — leaderboard global (validado en servidor)

✨ New

SOC Range «El Turno»: Fase 3 — línea de tiempo interactiva

✨ New

SOC Range «El Turno»: Fase 3 — niveles de dificultad

✨ New

SOC Range «El Turno»: Fase 3 — dos familias más (4 en total)

✨ New

SOC Range «El Turno»: Fase 3 (segunda familia de incidente)

✨ New

SOC Range «El Turno»: Fase 2 (motor procedural)

✨ New

«El Turno» (SOC Range) · Fase 1 MVP

✨ New

Página insignia «Cobertura ATT&CK» (mapa del arsenal)

✨ New

Página insignia «Disección de un phishing» (interactiva)

✨ New

Página insignia «Anatomía de un ataque» (scrollytelling)

✨ New

Decodificador de PowerShell (-EncodedCommand)

✨ New

Explicador «Cómo cae tu contraseña»

✨ New

Arsenal: tira «✨ Nuevas» en /tools

✨ New

Blog: «Los Windows Event IDs que todo SOC debería vigilar»

✨ New

Blog: «De una CVE a una detección»

✨ New

Blog: «Entender una regex (y ReDoS)»

✨ New

Blog: «Endurece tu Dockerfile»

✨ New

Puente CVE → detección

✨ New

Insignia de seguridad embebible (badge SVG)

✨ New

Generador de configuración TLS (estilo Mozilla)

✨ New

Linter de Dockerfile / IaC

✨ New

Explicador de Regex (desglose + probador + ReDoS)

✨ New

Evaluador de CSP

✨ New

Reto del día (retención)

✨ New

Explicador IA (Modelo B) del comando Nmap

✨ New

Linter de detecciones (Sigma / YARA)

✨ New

SEO programático: familia de IDs de eventos de Windows

✨ New

Threat Radar: distribución (feed RSS + widget "CVE del día")

✨ New

Tests JS de herramientas (dual-mode)

✨ New

SEO programático: 4 familias de referencia (puertos, HTTP, DNS, firmas)

✨ New

Widgets embebibles ("CyberEscudo Embed")

✨ New

Asistente de Seguridad offline (Modelo B)

✨ New

SEO programático: referencia MITRE ATT&CK (/attack)

✨ New

Radar de Postura: escáner de seguridad de dominio con nota A–F

✨ New

Threat Radar: inteligencia de vulnerabilidades en vivo

✨ New

Post del blog: «Nmap sin miedo»

✨ New

Post del blog: «Tu primera regla YARA»

✨ New

Post del blog: «La extensión miente» (magic bytes)

✨ New

Identificador de firmas de fichero / magic bytes (/tool-magic)

✨ New

Write-ups de los 3 labs (WAF, SQLi, Prompt Injection)

✨ New

Generador de reglas YARA (/tool-yara)

✨ New

Constructor de comandos Nmap (/tool-nmap)

✨ New

Hub de la Zona CTF (/ctf/)

✨ New

esquema SQL del leaderboard (db/schema.sql)

📝 Blog

nuevo artículo: inyección de prompts

✨ New

Explicación con IA en el Analizador de Logs (Modelo B)

✨ New

Auditor de System Prompts (/tool-prompt-audit)

✨ New

Prompt Injection Lab (/lab-llm)

✨ New

Explicación con IA en el Triage de Email (Modelo B: offline)

✨ New

SQL Injection Lab (/lab-sqli)

✨ New

guard de regresión «herramienta muerta»

📝 Blog

nuevo artículo: cómo se evade un WAF

✨ New

Laboratorio nuevo: WAF Bypass Lab

✨ New

write-ups de las 3 misiones nuevas

✨ New

3 misiones CTF nuevas (atadas a las herramientas recientes)

🧰 Tool

Analizador de robots.txt / security.txt

🧰 Tool

Decodificador de Certificados X.509

📝 Blog

nuevo artículo: los secretos en git no se borran, se rotan

🧰 Tool

Escáner de Secretos

✨ New

«Novedades» más clara y de cara al usuario

📝 Blog

3 artículos nuevos (JWT, Sigma, subdomain takeover)

🎨 Home

contadores del hero: animación restaurada + centrado

🎨 Home

sistema visual unificado (tarjeta + tokens)

🎨 Home

reenfoque «producto primero»

📝 Blog

nuevo artículo: CVSS v3.1 vs v4.0

🧰 Tool

soporte de CVSS v4.0 (completo)

🧰 Tool

ahora completa (Temporal + Entorno)

🧰 Tool

Calculadora CVSS v3.1

📝 Blog

nuevo artículo: encoding no es cifrado (cripto/CTF)

📝 Blog

nuevo artículo: reconocimiento pasivo (OSINT)

📝 Blog

nuevo artículo: persistencia por cron

📝 Blog

nuevo artículo de Red Team

🎨 Home

fondo del hero en 3D (canvas) reactivo al scroll

📝 Blog

nuevo artículo: CSP en la práctica

📝 Blog

compartir artículos

📝 Blog

UX de lectura

📝 Blog

últimos artículos en la home

📝 Blog

páginas de etiqueta (/blog/tag/<slug>)

✨ New

página pública de novedades (/novedades)

📝 Blog

nuevo artículo de blog

🧭 Web

breadcrumbs visibles

📝 Blog

nuevo artículo de blog

🎨 Home

above-the-fold

✨ New

panel privado (/panel) y mecanismo de secretos

✨ New

newsletter con envío real vía Brevo (opcional)

✨ New

artículos relacionados en el blog

✨ New

suscripción por email (newsletter) con consentimiento

📝 Blog

nuevo artículo de blog

📝 Blog

nuevo artículo de blog

✨ New

imágenes Open Graph por artículo de blog

📝 Blog

nuevo artículo de blog

✨ New

Panel de estado de servicios (/estado)

✨ New

Auditor SPF / DMARC / DKIM (tool-email-check)

✨ New

Generador SPF / DMARC / DKIM (tool-email-records)

✨ New

FAQ en todas las herramientas (cobertura 100%)

✨ New

anti-spam del formulario de contacto

✨ New

herramientas nuevas

✨ New

SEO / datos estructurados

✨ New

capa de plataforma (favoritos, recientes y «enviar a»)

✨ New

paleta de comandos (⌘K)

✨ New

¿Me afecta a mí?

✨ New

PWA

✨ New

Añadido

✨ New

Añadido

✨ New

Añadido

✨ New

Añadido

✨ New

Añadido

✨ New

Añadido

---

✨ New

Añadido

Show technical detail and fixes

Corregido — Espera del examen en singular

  • Con menos de un minuto de espera, el aviso de «muchos exámenes seguidos» decía «dentro de 1 minutos»; ahora concuerda (ES/EN).

Corregido — La redirección de «.php» se comía los dominios que lo llevan

  • La regla de .htaccess que quita el .php de las URL buscaba «.php» en toda la petición, consulta incluida: /email/www.php.net/ o ?domain=www.php.net acababan en «www». Ahora solo mira el final de la ruta (comprobado en el Apache local con las reglas reales); lo encontró la revisión de la fase 14.

Corregido — El catálogo KEV llevaba parado desde el 3 de agosto

  • kev-cache.json estaba versionado en git y ningún cron lo actualizaba en producción (si lo hubiera hecho, habría chocado con el git pull del despliegue), así que el Threat Radar llevaba siete semanas sin marcar como explotadas las vulnerabilidades que CISA añadió desde entonces. Ahora sigue el patrón del radar: scripts/sync-kev.php escribe un fichero vivo fuera de git (con escritura atómica) y en git va una semilla, kev-cache.seed.json (actualizada hoy: catálogo 2026.09.23, 1721 vulnerabilidades), que se lee mientras el cron no haya corrido. El radar, «¿Qué parcheo primero?», «¿Me afecta a mí?» y el boletín leen lo mismo (ce_kev()).

Corregido — El auditor penalizaba los SPF que delegan con redirect= (metodología 2026.2)

  • v=spf1 redirect=spf.iberdrola.com perdía 40 puntos por «no termina en un mecanismo all», cuando sin all manda el SPF del destino (RFC 7208 §6.1), y el de Iberdrola termina en -all. Salía con B; ahora, A. Es un patrón habitual en empresas grandes.
  • Los dos motores (navegador y servidor, con la paridad bloqueándolos) resuelven el destino: se evalúa su all; si no publica un único SPF, es permerror; si no se puede consultar, no se penaliza y se avisa. Con all propio, redirect= se ignora, como dice el RFC. Siete casos nuevos en los fixtures de paridad.
  • Cambia la nota, así que la metodología sube a 2026.2: la caché de /email/ se invalida sola.

Corregido — El minificador de CSS rompía selectores

  • scripts/minify.php quitaba el espacio delante de «:», y .x :is(a) (un descendiente) pasaba a ser .x:is(a) (el propio elemento): once selectores de cinco hojas dejaban de aplicarse en el .min.css, entre ellos estilos :focus-visible. Como asset() sirve el .min cuando no es más viejo que el .css, lo probado en local podía no ser lo servido.
  • Ahora solo se quita el espacio de después, y un test comprueba que cada .min.css es exactamente lo que genera el minificador desde su .css.

Corregido — En el informe

  • Textos que la revisión encontró exagerados: «reciben informes DMARC» pasa a «piden» (dos de ellas mandan los informes a otro dominio que no lo autoriza, RFC 7489 §7.1); «no reciben correo» pasa a «sin registro MX»; «ninguna conexión con sus servidores» pasa a «nunca nos conectamos» (Google sí consulta sus DNS autoritativos), y la nota suma también la higiene de DMARC. La tarjeta para redes, que suele verse sola, dice «en el dominio de su web corporativa», y su texto alternativo describe la cifra.
  • Los enlaces dentro de los párrafos se distinguían solo por el color: el control de accesibilidad del CI (axe, link-in-text-block) los rechazó en las dos versiones. Ahora van subrayados.
  • build-ibex35-report.php escribe primero todos los temporales y renombra después, con reintentos: en Windows, el servidor de desarrollo retenía el CSV que había servido y la instantánea quedaba a medias.
  • Las fechas sin hora («2024-07-22») salían con un día menos: se interpretaban como medianoche en hora de Madrid y se formateaban en UTC. Visto en la captura de la propia página, antes de publicar; ahora hay un test para ese caso.
  • scripts/gen-og.php no tenía candado de CLI. El .htaccess ya bloquea /scripts/; ahora tiene la segunda capa, como los crons.

Corregido — El aviso de «sin SPF» exageraba

  • Ante un dominio sin SPF, el auditor decía «cualquiera puede enviar correo en nombre de tu dominio». No siempre es verdad: si el DMARC —propio o heredado del dominio principal— está en reject, ese correo se rechaza igual (www.paypal.com es el caso real). Ahora dice lo que de verdad falta —los receptores no pueden comprobar qué servidores envían en nombre del dominio— y qué publicar: un SPF con los servidores de envío o, si el dominio no envía correo, v=spf1 -all. La nota no cambia: un nombre sin SPF sigue siendo un hueco.
  • La misma frase estaba en el analizador DNS, en el triaje de correos y en la explicación pregenerada del explicador de phishing («…cualquiera puede enviar en su nombre sin dejar rastro»); corregida en los cinco sitios (con el texto adaptado al triaje y al explicador, donde el dominio es el del remitente), y un test que recorre los cinco impide que vuelva. +1 test JS (697 → 698), guard falsificado 4/4.

Corregido — Las auditorías de dominios .com daban 403 en producción

  • La verificación en producción, nada más desplegar la fase 6, lo encontró: /email/google.com daba 403, y con él cualquier .com —cyberescudo.com, casi todo el IBEX 35—, mientras /email/example.org o /email/ejemplo.es daban 200. El 403 no salía del código (la página nunca lo devuelve) sino de una capa anterior: todo encaja con el cortafuegos de aplicaciones del servidor (ModSecurity), que bloquea las rutas cuyo último tramo acaba en una extensión restringida, y .com lo es (ejecutables de MS-DOS). En local no hay cortafuegos que lo reprodujera.
  • La URL pasa a terminar en barra (/email/google.com/): el último tramo queda vacío y no hay extensión que bloquear —comprobado en producción: con barra, 200—. Sin barra o en mayúsculas redirige con 301 a esa forma, conservando los selectores. El enlace «Compartir» del auditor, los códigos de la insignia y el radar ya la usan. La configuración de seguridad del servidor no se ha tocado.
  • Verificación: +3 tests PHP (1596 → 1599), guards falsificados 5/5; en local, 301 a la forma con barra (con los selectores), 200 con barra y el enlace «Compartir» del auditor con barra.

Seguridad — Todos los crons rechazan ejecutarse desde la web

  • /scripts/ ya devuelve 403 por .htaccess (verificado en producción), pero dos de los cinco crons no tenían la segunda capa —el candado PHP_SAPI !== 'cli' que sí llevaban los otros tres—: sync-wp-vulns.php y sync-kev.php. El primero es justo el que tiene detrás una API con límite de peticiones y una descarga de 117 MB: si algún día el servidor no aplicara el .htaccess, cualquiera podría dispararlo.
  • Añadido el candado a los dos, y un test que recorre todos los scripts/sync-*.php y update-*.php: el quinto cron sin candado lo encontró el propio test, no yo. Si alguien añade un sexto sin él, se verá en rojo.

Corregido — Fallos de la auditoría de correo que habría publicado el informe

  • Las claves Ed25519 y las RSA de 2048 bits que acababan en p== se marcaban como «clave débil»: el heurístico buscaba un patrón en vez de medir la longitud de la clave.
  • -ALL en mayúsculas se leía como «no hay mecanismo all»; los mecanismos SPF no distinguen mayúsculas (RFC 7208).
  • El radar no unía los fragmentos de un TXT partido (los SPF largos de las grandes empresas llegan así).
  • La media se calculaba en coma flotante (90 * 0.35 = 31,4999…) y el redondeo dependía del lenguaje; ahora es entera.
  • A una A con ~all y DKIM no detectado se le ofrecía ayuda («¿te ayudo a arreglarlo?»): el DKIM no detectado ya no cuenta para esa oferta.
  • Los subdominios salían sin DMARC: www.paypal.com sacaba F aunque hereda el p=reject de paypal.com. Un receptor que no encuentra _dmarc en el subdominio aplica el del dominio organizativo, con su sp= si lo tiene (RFC 7489 §6.6.3). Ahora el auditor sube por los dominios superiores (hasta 5, nunca hasta el TLD), aplica el primero que encuentra y dice de cuál viene.
  • Con dos registros DMARC se leía el primero, y podía salir una A. Los receptores no aplican ninguno: ahora es F, con su propio motivo.
  • Cualquier CNAME en un selector DKIM contaba como «DKIM delegado 👌», aunque el destino no existiera o no tuviera clave. Ahora solo cuentan las claves que existen, y un CNAME colgante se avisa: si otro pudiera registrar ese destino, firmaría correo como tu dominio.
  • Retirar bien una clave DKIM restaba 40 puntos y salía como hallazgo grave: p= vacío es justo como el RFC 6376 dice que se revoca una clave. Ahora se reconoce y no penaliza; lo que sí se avisa es una clave sin p= (registro roto).
  • Un fallo del DNS se puntuaba como «sin registros»: si el resolvedor devolvía SERVFAIL, el auditor del navegador calculaba una nota sobre una consulta fallida. Ahora dice que no ha podido consultar y no pone nota (el servidor responde 503).

Seguridad

  • Las cachés por dominio (assets/data/badge-cache/ y la nueva email-cache/) no tenían ninguna regla que las bloqueara desde la web: si un dominio se había escaneado, su JSON se podía pedir directamente. No encontré ninguno en producción para demostrarlo (los que probé daban 404: no existían), pero nada lo impedía. Ahora devuelven 403 (.htaccess, router y lib/web-path.php).
  • Todo lo que llega del DNS se escapa en HTML y SVG: quien controla un dominio puede publicar <script> en su SPF. Demostrado con un caso malicioso en los tests.
  • Techo global de auditorías nuevas (300 por hora para todo el sitio): el límite por sesión se salta sin cookies, y cada auditoría nueva lanza una veintena de consultas DNS. Lo que está en caché no cuenta y no se guarda nada de quien consulta; al superarlo, 503 con Retry-After.
  • Verificación: +432 tests PHP y +60 JS (1159 → 1591, 637 → 697); guards falsificados 36/36 (15 + 17 de la segunda ronda + 4 del sitemap y el smoke test); revisión separada por un revisor sin el contexto de la implementación, cuyos hallazgos son las cinco últimas correcciones de arriba, la insignia con selectores y el techo global; auditoría delta de toda la fase sin bloqueantes nuevos (4 avisos de tamaño). Comprobado en vivo: www.paypal.com hereda y sale D (no F), un dominio con SERVFAIL no recibe nota ni en el navegador ni en el servidor, y página e insignia coinciden con ?s=.

Corregido — Los crons del radar y de Wordfence escriben de forma atómica y dicen por qué fallan

  • Threat Radar llevaba 21 días sin actualizarse en producción. Al crear por fin el cron en Plesk, el script hizo todo el trabajo (263 candidatas, CIRCL, NVD, 215 s) y falló en el último paso con un «no se pudo escribir» sin motivo. La causa: threat-radar.json está ignorado por git y el que había en el servidor lo creó otro usuario a mano; el cron, que corre como el usuario del dominio, no podía sobrescribirlo.
  • Ahora sync-threat-radar.php y sync-wp-vulns.php escriben temporal + rename(): nadie lee un JSON a medias mientras el cron corre, y rename solo necesita permiso sobre el directorio, no sobre el fichero anterior. Si aun así falla, el mensaje trae el motivo del sistema («Permission denied»…) y qué hacer — la lección del fallo silencioso, otra vez.
  • assets/data/*.tmp ignorado en git por si un temporal quedara huérfano. Patrón verificado en local (escribe, reemplaza, no deja basura, y el fallo explica el motivo).

Corregido — El filtro anti-spam dejaba pasar el spam de marketing, y el panel no enseñaba lo que descartaba

  • El filtro de contenido del formulario cazaba los mensajes en cirílico pero puntuaba 0 los de marketing en inglés («video promotion», «Instagram followers») y el galimatías de teclado. Probado contra los 7 mensajes de spam reales que llegaron al panel: antes cazaba 3, ahora 7 de 7.
  • Dos reglas nuevas: frases de plantilla de marketing (exactas a propósito — «video» o «results» sueltas no puntúan) y texto sin vocales (por debajo del 18 % no es ningún idioma; el texto real ronda el 40-55 %). 0 falsos positivos en 6 mensajes legítimos diseñados para parecerse al spam: siglas SPF DKIM DMARC v=spf1, un cliente que menciona «your video on LinkedIn» de pasada, un presupuesto.
  • La función sale de la página a lib/contact-spam.php para poder probarla sin renderizar nada. +16 tests; guards falsificados, incluida la mutación realista de «simplificar las frases a palabras sueltas», que es como suelen romperse estos filtros.
  • Nueva sección «Spam descartado» en el panel. Hasta ahora lo descartado se registraba en un log que nadie miraba: si el filtro se equivocaba con un cliente real, era invisible. Ahora se ve, con el motivo (honeypot, tiempo o contenido).

Corregido — Tres cosas que los datos del panel tenían mal

  • La portada salía partida en dos filas (/ y /index): el logo de la cabecera enlazaba a /index. Ahora enlaza a / y el contador normaliza /index por si queda en marcadores.
  • «Últimos 7 días» eran 8: la consulta usaba >= hoy − 7, que incluye 8 días, mientras el periodo anterior tenía 7. El porcentaje de variación partía con sesgo al alza. Las cuatro ventanas miden ahora exactamente lo que dicen.
  • El «+298 %» era ilusión de arranque: el contador empezó el 5 de septiembre, así que «los 7 días anteriores» tenían 3 con datos reales. Ahora solo se compara cuando el periodo anterior está completo en los datos (ce_stats_prev_complete()); hasta entonces, «sin comparación». +14 tests (1101 → 1131).

Corregido — El panel confundía páginas vistas con visitantes

  • La tarjeta ponía «visitas» sobre una cifra que son páginas vistas. Al compararla con los «usuarios» de Google Analytics parecía que el contador inflaba el tráfico —632 frente a 5 al día—, cuando lo que pasaba es que son unidades distintas: quien entra y mira 12 páginas suma 12 aquí y *un* usuario en GA.
  • Lo agravaba que la navegación interna se guarda con origen vacío (correcto: venir de tu propia web no es una fuente de tráfico) y en la tabla sale como (directo). En los datos reales eran 588 de 632: casi todo tránsito interno, no llegadas.
  • Nueva tarjeta «desde fuera» (ce_stats_external()): páginas vistas con origen externo, la única cifra de aquí comparable con los usuarios de GA. En los datos reales daban ~9/día frente a los 5 de GA — esa diferencia de 1,8× es la gente que rechaza el banner, que es exactamente lo que este contador existe para ver.
  • La tarjeta grande pasa a llamarse «páginas vistas», la sección a «Tráfico» y las columnas a «Vistas», con un aviso explicando que (directo) incluye la navegación interna. +5 tests PHP (1096 → 1101).

Corregido — El banner de cookies era ilegible sobre el hero

  • El aviso de consentimiento usaba var(--bg-card2), que es rgba(255,255,255,0.022): un 2,2 % de blanco y sin backdrop-filter. Sobre el hero, los botones «Ver mi trabajo», «CV», «LinkedIn» y la píldora de Recruiter se veían a través del texto legal, que quedaba mezclado con ellos.
  • No es solo estética: es el aviso que hay que leer para decidir sobre el consentimiento. Un texto que depende de lo que tenga detrás no cumple su función.
  • Ahora usa el patrón que el propio sitio ya aplica a los paneles flotantes: fondo rgba(10,14,20,.97) con blur(16px), borde cyan tenue y sombra más marcada, para que se lea como una capa por encima y no como parte de la página.
  • Contraste medido en el navegador sobre el fondo compuesto: 15,50 el texto, 10,78 el enlace «Más info» y 8,58 el botón «Rechazar» — todos muy por encima del 4,5 que exige AA. Comprobado también en móvil (375 px): el banner se apila sin desbordar. style.min.css regenerado.

Cambiado — Hero de la home: recuperado el eslogan «Protegiendo el Mundo Digital»

  • Ajuste de marca: el titular sigue siendo el nombre (identifica de inmediato a quien selecciona), y vuelve el eslogan «Protegiendo el Mundo Digital» / «Protecting the Digital World» como línea bajo él, con el degradado original en la palabra destacada. Se reaprovecha $t['hero']['title1']/titleHighlight (bilingüe ES/EN), que seguían en lang/. Híbrido: identidad + eslogan, sin perder ninguno de los dos.

Cambiado — Espaciado del pill «Recruiter» en el hero

  • El botón «👔 ¿Recruiter? Vista de 60 segundos» de la portada baja un poco: más aire entre la fila de CTAs y el pill (margen superior 0.2rem → 1.1rem), para que respire y no quede pegado a los botones.

Mejorado — Descubribilidad: nav resalta el portfolio + LinkedIn en el footer

  • Cierre de la Fase 1: el dropdown «Sobre Mí» ya resalta (is-hot) las piezas de empleabilidad —Proof of Work y Recruiter Mode, que salen primero— igual que hacen los menús de Arsenal/Labs/Aprender; antes ese bucle ignoraba el flag hot y quedaban visualmente planas. Así, desde cualquier página interior, un técnico de selección ve el camino a la evidencia.
  • LinkedIn ahora está en el footer (junto a Contacto), presente en todo el sitio; hasta ahora solo aparecía en el hero de la portada. Sin tocar el resto de la barra ni CTAs (el hero ya tenía CV·LinkedIn·Contacto·«Ver mi trabajo»·pill Recruiter).

Cambiado — Home reorientada a empleabilidad (analista + proof of work)

  • El hero de la portada pasa de «producto primero» (vende las tools) a «analista primero»: nombre + rol (Analista de Ciberseguridad · SOC · Detection Engineering · Sentinel · KQL · eCPPT) y el lema *«no lo digo: lo construyo»*, con 4 CTAs de empleabilidad — Ver mi trabajo, CV, LinkedIn, Contacto. Primer paso de la Fase 1 del plan de evolución.
  • Métricas del hero ahora reales y derivadas del catálogo, cada una clicable hacia su evidencia: 65 herramientas → /tools, 14 laboratorios → /tools, 22 técnicas ATT&CK → /cobertura-attack, 26 artículos → /blog (nada inventado; es la filosofía «demuestra, no digas»). Se mantiene la estética terminal y las secciones que son la prueba (Arsenal, Threat Radar, labs, blog). Bilingüe ES/EN, sin cambios de rendimiento.

Mejorado — Auditor SPF/DMARC/DKIM: plan de migración a p=reject

  • El auditor (/tool-email-check) ya no solo pone nota: tras la consulta en vivo, traza el plan de migración por fases hasta DMARC p=reject sin romper el correo legítimo, según en qué punto esté el dominio. Publicar p=none + rua (monitorizar) → alinear SPF/DKIM de todos los remitentes → p=quarantine; pct=25 → 50 → 100 → p=reject; sp=reject. Cada paso trae el registro DMARC a publicar (copiable, con el dominio real en el rua), cuánto esperar y qué vigilar en los informes (ojo a reenvíos/listas que rompen SPF → DKIM/ARC). Si el dominio ya está en p=reject, lo felicita y avisa si sp= es más débil.
  • Es la otra cara del Triage de Email: uno analiza el correo que recibes, este protege el que envías. No es tool nueva: se potencia la existente reutilizando su consulta DoH y el parseo de DMARC. Motor puro dmarcPlan(state) en assets/js/email-check.js (dual-mode; devuelve los pasos que faltan; i18n y el registro en el DOM). +5 tests (email-check 6 → 11): sin DMARC, none, quarantine escalonado por pct, reject/sp débil, y sp=reject en el registro final.

Cambiado — Consolidación de los flagships «Cómo funciona» (DRY, sin cambios visibles)

  • Los tres flagships de la serie «Cómo funciona» (/handshake-tls, /login-con-google, /viaje-de-una-consulta-dns) repetían, casi calcados, el mismo stepper (acumular pasos, colapsar los pasados con [hidden], contador aria-live, flechas del teclado, foco al encabezado activo) y el mismo CSS del carril (actores/carril/cabecera/cuerpo/controles). Se ha consolidado en una sola pieza reutilizable.
  • Nuevo motor assets/js/flow-stepper.js: función genérica ceFlowStepper() + clamp pura flowClampStep() (dual-mode, exportada para test). Auto-inicializa cada .flow-demo en DOMContentLoaded, así que las páginas no necesitan <script> inline (compatible con la CSP con nonce).
  • Nueva hoja assets/css/flow.css con clases genéricas .flow-* (carril + shell de página), cargada por $pageStyles. Lados neutros .a/.b/.both y modificador .act.
  • Migradas las tres páginas a .flow-* y data-flow; eliminados los <style> inline y los tres JS por página (handshake-tls.js, oauth-flow.js, dns-flow.js).
  • Sin regresiones: DOM, ARIA, orden de encabezados (h1→h2→h3), mejora progresiva (sin JS = artículo) y comportamiento del stepper idénticos. Verificado en navegador (las 3, escritorio y móvil) y con axe (0 errores en las 3). No cambian slugs/URLs, catálogo ni la tira «Nuevas».
  • Tests: los tres tests de clamp se unifican en tests/flow-stepper.test.js; guards PHP actualizados (flow-stepper.js, flow-lane). Suite intacta: PHP 576, JS 443.

Mejorado — tool-url: radiografía de engaño (anti-phishing)

  • La herramienta de URL (/tool-url) ya no solo codifica/decodifica y desmonta: ahora emite un veredicto de engaño (🔴 alto / 🟠 sospechoso / 🟡 menor / 🟢 sin señales) con el dominio real y una lista de señales explicadas. Detecta: truco de credenciales @ (el destino real es el host, no lo de antes de la @), subdominios que imitan marcas (paypal.com.sitio-falso.ru → dominio real sitio-falso.ru), Punycode/homográficos, TLD abusados (.zip, .tk…), acortadores, redirecciones abiertas (parámetros que llevan otra URL), IP como host, puerto no estándar, doble codificación, HTTP y parámetros de rastreo.
  • No se creó una tool nueva: se potenció la existente (ya hacía decode + desglose + Punycode) reutilizando su parseo. Motor puro urlDeception()/registrableDomain() en assets/js/url-tools.js (dual-mode, devuelve señales por código; el DOM traduce ES/EN). Veredicto por peso de señales; el rastreo es informativo (no eleva el riesgo).
  • De paso, arreglado que los botones «Ejemplo» y «Limpiar» no funcionaban con el campo vacío. +7 tests JS (tests/url-tools.test.js, → 11): truco @, marca en subdominio, punycode/acortador/TLD/redirección, rastreo=info, URL limpia y entrada inválida.

Corregido — a11y intermitente en «Anatomía de un ataque»

  • El revelado por scroll de las tarjetas usaba opacity (0→1). axe evalúa el contraste de un texto a opacity<1 y lo contaba como fallo, de forma intermitente según cuántas tarjetas se hubieran revelado al correr el gate. Ahora el revelado es solo transform (las tarjetas son siempre opacas) → contraste determinista, mismo efecto visual (deslizamiento). Añadido de paso un cross-link recíproco de la Anatomía → Cobertura ATT&CK.

Eliminado — Asset sin usar logo-cyberescudo.png

  • Se borra assets/img/logo-cyberescudo.png (497×502, ~201 KB): no lo referencia ningún fichero (el logo del JSON-LD usa icon-512.png, la tarjeta OG es og-cover.png y el logo de la nav/footer es texto/SVG). Ya se había quitado del precache del service worker (ce-v6); esto completa esa limpieza eliminando el fichero del repo. El máster vectorial de marca sigue en logo-bisel.svg.

Cambiado — Blog: cross-link del post de CSP al evaluador

  • El artículo «CSP en la práctica» ahora enlaza también al evaluador de CSP (/tool-csp-eval) en su sección «Verifícala» (ES + EN), cerrando el último hueco de linking del lote 2 (crear → verificar cabeceras → juzgar la CSP).

Cambiado — Auditor JWT: lógica pura testeable + confusión de algoritmo y jku/x5u

  • El análisis de seguridad de JWT (jwt-analyze.js) se extrae a una función pura y testeable ce_jwt_audit(header, payload, parts, {now, weakSecret}) que devuelve hallazgos con códigos neutros ({code, sev, data}); el mapa código→texto (i18n) queda en el DOM (patrón dual-mode con separación de i18n). Determinista (se le inyecta now). +12 tests JS (tests/jwt-audit.test.js) → 295.
  • Nuevos chequeos: confusión de algoritmo (aviso en algoritmos asimétricos RS/ES/PS: el servidor debe fijar el algoritmo esperado o el token es falsificable) y claves externas jku/x5u en la cabecera (vector de SSRF e inyección de clave). Se mantienen los existentes: alg=none, secreto débil por diccionario HMAC en el navegador, exp/nbf/iat, kid inyectable, datos sensibles y claims de autorización.
  • Comportamiento del navegador intacto (verificado: ejemplo con secreto débil caducado y token RS256+jku), sin errores de consola.

Corregido — Threat Radar: calidad del snapshot diario (CIRCL 429)

  • El ETL (scripts/sync-threat-radar.php) dejaba >50% del top-40 sin CVSS ni resumen: CIRCL (cve.circl.lu) devuelve 429 ante la ráfaga de 60 peticiones en 6 lotes de 10 en paralelo (su API corta ráfagas de ~>10). El fallback de NVD, topado a 15, no llegaba a cubrir todos los huecos.
  • Arreglo híbrido: se estrangula CIRCL (lotes de 6 + pausa de 6 s entre lotes, buen ciudadano; ya recupera de 11 a 23 de 60 aun con la IP penalizada) y se sube el tope de relleno NVD de 15 a 40, de modo que cubra el top-40 completo aunque CIRCL falle del todo. Verificado en el peor caso (CIRCL saturado): el snapshot pasa de 21/40 sin CVSS a 0/40 (0 sin resumen, 0 sin vector). Coste: el ETL diario tarda ~3-4,5 min (irrelevante en un cron). La degradación con gracia sigue intacta (si no hay candidatos, conserva el snapshot anterior).

Cambiado — Dashboard de /ctf/ coherente con el Perfil (Nivel unificado)

  • El dashboard de la Zona CTF mostraba un «Rango» calculado por retos resueltos, mientras el Perfil muestra el Nivel por XP: dos escalas para el mismo agente. Ahora el dashboard muestra el mismo Nivel de AgentCore que el Perfil y el rango temático (RECRUIT → OPERATOR → SPECIALIST → GHOST_HACKER) se deriva del nivel, no de los resueltos. Cierra la coherencia iniciada con la unificación de XP. Verificado: con XP 2200 → Nivel 7 en Perfil y en el dashboard.

Cambiado — style.css al formato compacto de la casa (lint limpio)

  • Reformateado assets/css/style.css al estilo de la casa (sin espacio tras : ni ;): 1827 → 0 avisos de lint. Es un cambio solo de whitespace del fuente (no une líneas ni toca valores multi-token, cadenas, url() ni comentarios de código). Verificado con red de seguridad: style.min.css queda byte a byte idéntico al anterior, así que lo que se sirve en producción no cambia. Los 7 comentarios que contenían :/; se reformularon.

Cambiado — Estilos del blog extraídos a blog.css

  • El bloque <style> del artículo (.article), que se duplicaba en los 20 posts (~2 KB por página), se extrae a assets/css/blog.css (copia superset que cubre todas las variantes que había). Se carga solo en el blog mediante un nuevo hook $pageStyles en el header (espejo de $pageScripts): cada post declara $pageStyles = ['blog.css'] antes de incluir el header. Menos peso por página y una única fuente de estilo. blog.min.css generado y formato conforme a la guía de estilo (lint limpio).

Cambiado — XP unificado entre el Perfil y la Zona CTF

  • Había dos contadores de XP que divergían: el antiguo cyber_xp (lo subían solo los retos, desde la terminal) y el de AgentCore (cyberescudo_agent_v1, lo suben solo algunas herramientas y es el que muestra el Perfil). Ahora hay un único contador (AgentCore):
  • Resolver un reto/misión suma al mismo XP que ve el Perfil (grantXP → AgentCore.addXP), con su aviso emergente y subida de nivel.
  • El dashboard de /ctf/ lee el XP de AgentCore (así refleja también el XP ganado con herramientas).
  • Migración única e idempotente: el cyber_xp acumulado se funde en AgentCore la primera vez que se carga la página y se borra la clave antigua (sin doble conteo).

Cambiado — Misiones y CTF unificados en una sola Zona CTF (/ctf/)

  • /missions/ y /ctf/ se fusionan en una única página (/ctf/ = «Zona CTF»), porque ya eran lo mismo: retos validados en el servidor con submit <ID> <FLAG> que puntúan en el mismo ranking. Ahora conviven los 47 retos (20 misiones guiadas + 27 retos CTF) con:
  • Dashboard arriba: anillo de progreso sobre el total (47), XP, rango (RECRUIT → OPERATOR → SPECIALIST → GHOST_HACKER) y contador de resueltos, calculado en el cliente desde cyber_missions/cyber_xp.
  • Filtros combinables por Tipo (Todos · Misión guiada · Reto CTF) y Dificultad (Easy/Medium/Hard/Insane), más toggle de vista Tarjetas ↔ Lista.
  • Estado resuelto reflejado en tarjetas y tabla (detección por cualquiera de los IDs del reto).
  • Las 20 misiones guiadas pasan a ce_missions() en lib/ctf-flags.php (fuente única, junto a ce_ctf_series()). missions/index.php ahora hace 301 → /ctf/ conservando el idioma; las páginas de briefing (missions/*.php) siguen intactas.
  • Enlaces entrantes actualizados a /ctf/ (leaderboard, perfil, atajo PWA del manifest) y /missions/ retirado del gate de a11y (ya redirige). Paleta verde HackTheBox (#9fef00), pills de dificultad y a11y (enlaces en prosa subrayados) conservados.

Cambiado — rediseño del Centro de Misiones (estilo HackTheBox)

  • missions/index.php rediseñado con look HackTheBox en verde neón (#9fef00): cabecera dashboard (anillo de progreso, rango, barra XP, contador), pills de dificultad color-coded (Easy/Medium/Hard/Insane), filtro por dificultad y toggle de vista Tarjetas ↔ Lista (tabla), todo desde una fuente de datos única ($missions, 20 misiones). El contador es ahora dinámico (cuenta las tarjetas; antes fijo en 17). Preserva el marcado de resueltas (.mission-completed) y las stats del agente (XP/rango) del JS existente; el estado resuelto se refleja en ambas vistas. Enlace inline a /ctf/ subrayado (a11y).

Cambiado — CTF unificado: todo server-side y submit ID FLAG

  • Migradas las 30 misiones de la serie ctf/ del validador en cliente (flags hardcodeadas en missions.js, que no puntuaban y quedaban expuestas) al servidor. Nueva fuente única lib/ctf-flags.php (id→flag) que comparten api/validate-mission.php y las páginas ctf/ (muestran submit <ID> <FLAG> con ce_ctf_id_for($flag), solo tras resolver el reto).
  • Eliminado el bloque cliente y la ruta submit FLAG; ahora se exige submit <ID> <FLAG> (puntuación server-authoritative). Las 30 antiguas ya cuentan para el ranking. Guard de cobertura en CI (tests/php/ctf-flags.test.php): toda flag de ctf/ tiene ID en el mapa; IDs y flags únicos.

Corregido — el perfil avisa si el alias no vale para el ranking

  • profile.php: validación de alias en cliente con el mismo criterio que el servidor (ce_valid_nick: 2-24 caracteres, letras/números/espacio/_ . -). Antes, un alias con caracteres no válidos o longitud fuera de rango se aceptaba en silencio, pero el servidor lo rechazaba (reason: invalid_nick), así que el jugador nunca puntuaba y el perfil le decía «resuelve un reto para entrar» en bucle. Ahora sale un aviso claro y el badge se oculta hasta corregirlo.

Legal/cumplimiento — uso autorizado y divulgación de terceros

  • Análisis de legalidad de las 51 herramientas: ninguna ejecuta un acto ilegal. Las «ofensivas» (revshell, waf, wordlist, cloud, hashcrack, c2, bloodhound) son generadores/simuladores en el navegador; el OSINT es pasivo (APIs públicas: Cloudflare DoH, Shodan InternetDB, HIBP con k-anonymity, ipwho.is, archive.org); y los sondeos server-side (TLS y cabeceras) tienen anti-SSRF (rechazan IPs privadas/reservadas/metadatos, conexión fijada a la IP pública validada) y rate-limit.
  • Añadido aviso global de uso educativo/autorizado en el pie de página (ES/EN).
  • Política de privacidad: nueva sección «Herramientas y servicios de terceros» que divulga qué llamadas hacen algunas tools desde el navegador y que TLS/cabeceras salen del servidor solo hacia IPs públicas.
  • Pendiente (requiere datos del responsable): un Aviso Legal (LSSI) con identidad y contacto.

Corregido — dos herramientas más estaban muertas (no cargaban su JS)

  • Auditoría funcional de las 51 tools tras el caso del bastionado. /tool-auditor (Visual Code Auditor) y /tool-bloodhound (BloodHound AD Simulator) tenían su markup (#auditor-code, #bh-canvas) pero no cargaban labs-bundle.js, el motor que las hace funcionar: escanear código o dibujar el grafo no hacía nada. Añadido $pageScripts = ['labs-bundle.js'] en ambas. Verificado en navegador: el auditor detecta 3 vulnerabilidades en la plantilla PHP; el simulador dibuja el grafo AD y añade nodos.
  • De paso, el panel de info de bloodhound usaba un icono FontAwesome (que no carga): migrado a emoji (👤/🖥️/👥/🌐). Barrido confirmado: 0 iconos FontAwesome activos en todo el sitio; ningún otro tool sufre el patrón de «JS no cargado» ni el early-return del handler compartido.

Corregido — la herramienta de bastionado no funcionaba

  • /tool-bastioning estaba muerta: el botón «Generar Hardening» no hacía nada. arch-bastion.js comparte un único handler DOMContentLoaded con el diseñador de arquitectura y salía antes de tiempo (if(!btnGenerate) return;) en cuanto no encontraba #btn-generate — que solo existe en arch-designer, no en la página del tool. Eso impedía que se inicializara el motor de bastionado. Ahora initOSBastion() se ejecuta siempre (tiene su propio guard), antes del early-return. Verificado: el checklist rinde y el script se genera en /tool-bastioning, y arch-designer sigue igual (sin doble init).

Mantenimiento — iconos FontAwesome inyectados por JS

  • Sustituidos los <i class="fas fa-…"> que inyectaban arch-bastion.js, labs-bundle.js y profile.js (FontAwesome no carga en el sitio y salían como huecos vacíos): ahora emoji (✅/⚠️/💀/🖥️/🏅) y, para los spinners de carga, el .cyber-spinner CSS ya existente.

Mantenimiento — barrido de calidad

  • Eliminado CSS muerto: sección *About/contact* huérfana (.about-wrap, .profile-card, .contact-card, .contact-icon/-label/-email) y restos responsive de banners retirados (.banner-skill-tree*, .banner-phishing*, .btn-road, .bg-code, .phishing-btn). Detector de CSS sin uso: de 24 a 14 clases (las 14 restantes son falsos positivos que sí se componen dinámicamente en JS: bg-*, pill-*, status-*, tlp-*).
  • Limpiado el selector huérfano .profile-card, .contact-card del observer de main.js.
  • Quitadas dos líneas de depuración (console.log) del gestor de toasts XP en agent-core.js.
  • Los toasts de gamificación (XP/nivel/logro) ya no usan iconos FontAwesome (que no carga y dejaban un hueco vacío): ahora emoji ⚡/🆙/🏆.
  • Normalizados a formato compacto varios selectores pequeños en media queries. style.min.css regenerado.

Seguridad — rate-limit en la API de misiones (endurecimiento)

  • El endpoint de validación de flags (api/validate-mission.php) no tenía límite de envíos: se podían probar flags sin freno. Ahora aplica ce_rate_ok (40 intentos/hora por sesión → HTTP 429), como defensa en profundidad sobre una puntuación que ya era *server-authoritative*. También se añade un límite generoso a player-stats.php (120/h, lectura del ranking) y la cabecera X-Content-Type-Options: nosniff a ambas respuestas JSON. Verificado end-to-end: el envío nº 41 devuelve 429 y una sesión nueva sigue validando sin problema.

Tests — cobertura del backend PHP (nuevo frente)

  • Hasta ahora solo el JS tenía tests; el backend PHP no tenía ninguno. Nuevo runner propio (sin composer/phpunit) en tests/php/ (harness.php + run.php) con 46 aserciones sobre las funciones clave: ce_valid_nick (la barrera anti-XSS/inyección del nick del leaderboard, testeada a fondo), los helpers del catálogo (ce_item/ce_url/ce_title/ce_cat_items/ce_tools + integridad de todos los items), el blog (ce_tag_slug/ce_blog_post/ce_related_posts) y las FAQs (ce_faq + que toda Q&A trae las 4 variantes bilingües). Nuevo paso bloqueante en CI (php tests/php/run.php).

Tools — últimos restos de cian «duro» al token

  • 5 herramientas (OSINT Report, BloodHound, Patch Priority, Perimeter Scanner, Sigma Forge) usaban aún el cian puro #00ffff en su CSS; ahora usan var(--cyan) (el token del sitio, un teal algo más suave). El resto del arsenal ya estaba tokenizado, así que el cian queda uniforme en todas las páginas. Sin cambios de comportamiento (solo color).

Iconos — set antiguo unificado a currentColor

  • Los 12 iconos «antiguos» de icon() (shield, network, scan, code, alert, server, globe, sword, firewall, email, heart, logo) hardcodeaban #00ffff y un tamaño fijo en px. Ahora usan currentColor + 1em como los nuevos: heredan color y tamaño del contexto, así todo el set es coherente (p. ej. en la fila de botones de BloodHound, server ya no desentonaba en color/tamaño frente a user). Los contenedores de catálogo (.card-icon, .manual-cat-icon) fijan color:var(--cyan) + tamaño para conservar el cian y el tamaño de esos iconos. style.min.css regenerado.

Iconos — FontAwesome roto → SVG inline (CSP-safe)

  • Bug encontrado: FontAwesome nunca estaba cargado (ni CSS, ni kit, ni fuentes), así que los <i class="fa…"> de ~10 páginas (nav, /threat-hunting, /tool-*, architecture, profile…) salían invisibles. Sustituidos los 46 usos por SVG inline vía el helper icon(), ampliado con ~19 iconos *line-style* en currentColor (heredan color y tamaño del contexto, sin dependencias ni licencia). El badge de nivel del nav (presente en todas las páginas) también: agent-core.js ahora actualiza solo el número (#agent-nav-lvl) sin reintroducir el <i>. Los emoji del catálogo se mantienen.

CSS — barrido de código muerto + consistencia de tarjetas entre páginas

  • Eliminadas ~150 líneas de CSS muerto que dejaron las secciones retiradas de la home: .hero-alerts-link, .pulse-dot, .cyber-rss-*/.rss-* y todo el bloque .training-* (skill-tree / simulator / arsenal / scanner). Confirmado sin uso en PHP ni JS; @keyframes pulse se conserva (lo usan los retos CTF). style.min.css regenerado.
  • La tarjeta de /casos hardcodeaba el borde (#222) y el fondo (rgba(10,10,15,.5)): ahora usa los tokens (--border, --bg-card2, --radius) como el resto (home, /tools, /proyectos), así el sitio entero lee como un único sistema al navegar entre páginas.

Tests — cobertura del Multi Decoder (códecs + Magia + fuerza bruta)

  • multi-decode.js pasa a dual-mode: los códecs, el motor de Magia (cadena automática de decodificación hasta 12 capas) y la fuerza bruta (ROT-n y XOR de un byte) salen al scope del módulo; el render queda tras typeof document. tests/multi-decode.test.js (8 casos): los base-N (Base64/32/58, url), texto (hex/binario/decimal/Morse con separador de palabra), cifrados clásicos (ROT13 involutivo, Atbash), rechazo de entradas ajenas, el bonus de score/FLAG_RE, y —lo importante— que la Magia deshace una cadena de dos capas (Hex → Base64) hasta la flag, que ROT-n saca el desplazamiento correcto y que el XOR de un byte recupera la clave. 48 tests en total. Tool reverificada en navegador (Magia, códec manual, ROT y XOR) sin regresión.

Tests — cobertura del Conversor de timestamps y del Analizador de URL

  • epoch.js y url-tools.js pasan a dual-mode: el núcleo puro (parseEpoch/dayOfYear/isoWeek y encodeComponent/decodeDeep/tryB64/toUnicode) sale al scope del módulo para testearlo sin DOM; el render queda tras typeof document. tests/epoch.test.js (3 casos: autodetección de unidad s/ms/µs/ns por magnitud, unidad forzada, día del año y semana ISO en los límites de año) y tests/url-tools.test.js (4 casos: codificación de componente, doble codificación pelada en capas, base64 legible dentro de un parámetro y Punycode/IDN homográfico). 40 tests en total. Ambas herramientas verificadas en navegador tras el refactor (sin cambios de comportamiento).

Seguridad — revisión del backend CTF/gamificación (sin vulnerabilidades)

  • Revisado el backend con estado en servidor/BD (validate-mission, user-api, leaderboard, player-stats): sin vulnerabilidades reales. SQLi cubierto por *prepared statements* (el único LIMIT va con clamp a entero); XSS del nick bloqueado en 3 capas (regex de ce_valid_nick + e() en salida + badge de perfil solo con enteros del servidor); puntuación server-authoritative (hash_equals en tiempo constante, puntos solo tras flag correcta, INSERT IGNORE idempotente → el cliente no puede inflar); el IDOR de user-api es intencional y aislado (usuarios falsos, sin BD ni PII). Degradación elegante si cae la BD. Sin cambios necesarios.

Tests — cobertura del Auditor SPF/DMARC

  • email-check.js a dual-mode: auditSpf/auditDmarc/grade puros (hallazgos como datos {sev,es,en}). tests/email-check.test.js (6 casos): mecanismo all del SPF, múltiples registros (permerror), política DMARC (p/rua/pct/sp) y umbrales de nota A–F. 23 tests en total. Tool verificada en navegador (google.com → nota real).

Tests — cobertura ampliada a cron y hash-id

  • La red de tests (node --test, patrón dual-mode) cubre dos herramientas más: Analizador Cron (tests/cron.test.js: expansión de campos con rangos/pasos/listas/nombres, normalización domingo 7→0, y la semántica OR de día-del-mes/día-de-semana) e Identificador de Hashes (tests/hash-id.test.js: clasificación por prefijo y por longitud hex, MD5 vs NTLM, casos desconocidos). 17 tests en total (con CIDR). chmod se deja fuera: su lógica (r/w/x=4/2/1) es trivial y testearla obligaría a recablear el DOM sin ganancia real.

i18n — auditoría de fugas de idioma

  • Escaneadas las 174 páginas en modo EN buscando español colado (signos ¿/¡ y palabras de UI inequívocas). Resultado: limpio. La única aparición es /novedades, que renderiza el CHANGELOG (en español por diseño, artefacto de desarrollo); añadida una nota en la intro EN aclarándolo.

Tests — primera cobertura del núcleo de las herramientas

  • Arnés de tests con el runner nativo de Node (node --test, sin dependencias). Primera suite sobre la Calculadora CIDR (tests/cidr.test.js, 6 casos: red/broadcast/máscara IPv4, round-trip y compresión IPv6, límites de prefijo). Para ello cidr-tools.js pasa a dual-mode (núcleo puro exportable en Node + cableado DOM guardado por typeof document), sin cambiar cómo carga la página. Paso Unit tests añadido al CI. Patrón listo para extender a más herramientas.

Accesibilidad — teclado y foco en los widgets interactivos

  • Terminal (easter-egg): cierra con Esc y devuelve el foco al botón que la abre; role="dialog" + aria-label.
  • Paleta ⌘K: atrapa Tab dentro del diálogo (ya tenía role/aria-modal, Esc y retorno de foco).
  • Modal de donación (OSINT Report): role="dialog"/aria-modal/aria-labelledby, cierre con Esc, foco al abrir, retorno al cerrar y trampa de Tab.
  • Corregido de paso un bug latente en hero-3d.js: con viewport de 0 px el degradado del horizonte generaba rgba(…,NaN); ahora render() corta si no hay tamaño.

Rendimiento (LCP) y APIs — auditados, ya sólidos

  • LCP: la home ya está optimizada en cliente (CSS minificado sin bloquear render, fuentes precargadas con swap, JS diferido, sin imágenes pesadas). El ~3,8 s de Lighthouse es TTFB local (XAMPP sin opcache) + throttling, no representativo de producción. Palanca de servidor: confirmar opcache en Plesk.
  • APIs externas: los endpoints (exposure, cve, mac, tls, EPSS/KEV) ya llevan rate-limit por sesión, timeouts, validación de entrada y degradación; KEV cacheado a fichero. Los fetch de cliente (HIBP, ipwho, DoH) van con try/catch. Sin cambios necesarios.

Rendimiento — hero 3D verificado (sin regresión)

  • Lighthouse en la home con el fondo 3D nuevo: rendimiento 85 (antes 80), TBT 0 ms, CLS 0, LCP ~3,8 s (igual). El canvas va diferido y no penaliza la carga; confirmado, no supuesto.

Imágenes / PWA — menos peso precacheado + link-check más fino

  • El service worker precacheaba logo-cyberescudo.png (201 KB) que no se renderiza en ninguna página: eliminado del precache (sw.js, versión → ce-v6). Las imágenes de contenido (foto y avatar) ya se sirven en WebP con <picture>; las tarjetas OG y los iconos PWA se mantienen en PNG a propósito.
  • scripts/link-check.php ahora también comprueba los srcset (cazaría un WebP o *responsive* ausente que antes se colaba).

SEO — auditoría de datos estructurados

  • Revisado el JSON-LD del sitio: SoftwareApplication (gratis, isAccessibleForFree, offers 0 €), BlogPosting (imagen, fechas, autor, migas, keywords), Organization/Person/WebSite/ItemList/SearchAction — ya completo. Se decide no añadir FAQPage/HowTo: Google deprecó sus resultados enriquecidos en 2023 (FAQ solo para webs gov/salud), así que serían *cruft* sin beneficio para este sitio.

Calidad — barrido de auditoría y limpieza de CSS muerto

  • Auditoría con los scripts internos (seguridad, enlaces, CSS sin usar). Sin vulnerabilidades reales: los 142 avisos del security_audit son falsos positivos esperables en una web de herramientas de seguridad (payloads y ejemplos de código vulnerable como *contenido*); los ficheros que tocan BD no dieron ni un hallazgo (consultas preparadas). Sin enlaces rotos reales.
  • Eliminado CSS muerto: 61 clases sin uso en ningún php/js/html (87 reglas, −324 líneas; style.min.css −27%). Se conservan las familias que se construyen dinámicamente en JS/PHP (pill-*, status-*, tlp-*, bg-{SEVERIDAD}). Quitado también un comentario muerto que apuntaba a una imagen inexistente.

Accesibilidad — gate ampliado a 10 plantillas + arreglos reales

  • El chequeo axe/pa11y del CI pasa de 4 a 10 páginas: home, catálogo, herramienta, post, índice de blog, etiqueta, formulario de contacto, contenido, lab y hub de misiones (todas las plantillas distintas).
  • Corregidas las *violations* que destapó: contraste bajo en .blog-soon y estilos inline #333/#666 sobre fondo oscuro (soc-simulator, missions); y enlaces en prosa distinguibles solo por color (consentimiento del newsletter, leaderboard) ahora subrayados. El pulso de .status-live pasa de animar la opacidad —que hacía el contraste no determinista— a un *glow* estable.
  • scripts/router.php emula ahora también el rewrite /blog/tag/<slug> de .htaccess, para poder auditar esa plantilla en CI.

CI — chequeo de enlaces internos rotos

  • Nuevo scripts/link-check.php: recorre todas las páginas, extrae sus enlaces internos (<a>, <link>, <script>, <img>), resuelve relativos y URLs limpias, y falla ante cualquier destino interno ≥400. Autónomo (PHP + cURL, sin dependencias nuevas de CI). Añadido como paso tras el smoke-test. Estado: 725 destinos, 0 rotos.

i18n — autodetección de idioma en la primera visita

  • Sin ?lang= y sin cookie previa, el idioma se elige por Accept-Language del navegador (empieza en EN si el navegador lo prefiere; si no, ES). La elección manual sigue mandando. Los bots reciben ES (idioma canónico) para no confundir canonical/hreflang. Verificado: navegador EN→EN, ES→ES, Googlebot→ES.

Accesibilidad — chequeo en CI (pa11y/axe) ahora bloqueante

  • El chequeo axe/pa11y (.pa11yci.js) pasa de informativo a bloqueante: si aparece una violation real de a11y (contraste medible, roles, labels…), el build falla.
  • Al medir la «deuda de contraste» pendiente se vio que no eran fallos: los ~100 avisos eran resultados incomplete de axe («no puedo medir el contraste porque hay un degradado detrás / un elemento superpuesto»), que pa11y contaba como error. Se auditó a mano todo el texto sobre degradados (peor stop del gradiente compuesto sobre --bg) y todo pasa AA con holgura (p. ej. estrella de favoritos 8,4:1; banner de cookies 8–12:1; hero 5,9–16,5:1; hero-highlight con degradado recortado 5,7:1 sobre umbral 3:1 de texto grande).
  • Por eso los incomplete se degradan a *warning* (levelCapWhenNeedsReview) y no tumban el build; las violations reales siguen bloqueando. Versión de pa11y-ci fijada (@4.1.1) para que el gate no cambie solo, y el paso falla si no encuentra Chrome en el runner (sin fallback silencioso).
  • Correcciones de contraste previas que se mantienen: separadores del footer, contadores del catálogo (.cat-chip .n, .cat-group-head .n), estrella de favoritos y textos del banner de cookies.

SEO — sitemap con etiquetas e imágenes

  • El sitemap.xml incluye ahora las URLs de etiqueta y la extensión de imágenes (image:image): cada herramienta/lab/artículo declara su tarjeta OG y la home su portada (63 imágenes), para mejorar la indexación visual.

CI — mantenimiento

  • permissions: contents: read (mínimo privilegio), timeout-minutes en el job y Node 22 para los checks de JS. Excluidos del smoke-test/sitemap los handlers que necesitan parámetros (tag.php) o no son páginas.

Seguridad — security.txt (RFC 9116)

  • Añadido /.well-known/security.txt (y /security.txt) para divulgación responsable: generado por security-txt.php con Expires dinámico (+1 año, nunca caduca), Contact (email + formulario), Canonical, Preferred-Languages y Policy. Muy apropiado para una web de ciberseguridad. Servido por reescritura en .htaccess; fuera del sitemap.

Páginas de error 403 y 500 útiles

  • 403 ahora está al nivel del 404: estética de terminal, mensaje de acceso denegado, chips de herramientas a las que sí se puede entrar, buscador (⌘K) y «Volver a la Base». Reutiliza clases compartidas (nf-*) movidas a style.css.
  • 500 rediseñada manteniéndola autónoma (sin bootstrap.php, porque puede ser lo que ha fallado): estética de terminal en línea, bilingüe y con enlace de contacto.

Rendimiento — Core Web Vitals reales (RUM) a GA4

  • Nuevo assets/js/web-vitals.js (global): mide LCP, CLS, INP (aprox.) y TTFB de visitantes reales con PerformanceObserver y los envía a GA4 (evento web_vitals con nombre, valor y rating good/ni/poor) al ocultarse la página. Solo envía con consentimiento (usa window.gtag). Así se ve el rendimiento real de campo, no la estimación throttled de Lighthouse.

SEO — ItemList del arsenal

  • /tools emite ahora un ItemList con las 57 herramientas y labs (posición, nombre y URL absoluta), para que Google entienda el catálogo como una lista ordenada.

SEO — datos estructurados Organization + sitelinks searchbox

  • Añadido el nodo Organization al JSON-LD (nombre, url, logo 512×512, founder → la Person, sameAs) y publisher en WebSite, para que Google entienda la marca y pueda mostrar el logo.
  • Sitelinks searchbox: WebSite.potentialAction (SearchAction) apunta a /tools?q={search_term_string}. Para que sea real, catalog.js ahora lee ?q= al cargar y filtra el arsenal (p. ej. /tools?q=nmap muestra solo Nmap). Verificado el filtrado y el JSON-LD.

i18n — auditoría bilingüe

  • Repasadas las páginas de más tráfico y las de salida dinámica en modo EN (portada, catálogo, patch-priority): sin fugas de español. Corregido el único leak real encontrado: el placeholder del nombre de regla en el laboratorio de Sentinel (sentinel-lab.php) estaba fijo en español y ahora es bilingüe. (El panel privado y los retos CTF son ES por diseño.)

Accesibilidad — pestañas con teclado (resto de herramientas)

  • Extendido aria-tabs.js al resto de herramientas con pestañas: Defang, Generador de contraseñas, SSH, HTTP Builder y Enumeración Cloud (ya estaban SPF/DMARC/DKIM, WAF y Reverse Shell). El helper ahora detecta también la clase activa .on (además de .active), que usa el generador de contraseñas. Verificado el cambio de pestaña por teclado y la sincronización de aria-selected.

Accesibilidad — pestañas con teclado y ARIA

  • Nuevo assets/js/aria-tabs.js (global): mejora cualquier grupo de pestañas marcado con data-ce-tabs con role=tablist/tab, aria-selected, tabindex itinerante y navegación por flechas/Home/End, sin tocar la lógica de cada herramienta (el cambio de panel lo sigue haciendo el tool; el activo se detecta por su clase .active). Aplicado al Generador SPF/DMARC/DKIM, al WAF Bypass y al Reverse Shell. Verificado: al pulsar flechas cambia el panel y se refleja aria-selected.

Rendimiento — limpieza de CSS y evaluación de CSS crítico

  • CSS muerto: eliminadas reglas de una sola línea confirmadas sin uso (donaciones movidas al footer, medidor de contraseñas antiguo, banners retirados). El resto del CSS marcado como «sin usar» por las herramientas se aplica dinámicamente desde JS (sev-*, waf-*, cvss-*, cron-*…), así que no se toca para no romper nada; además va comprimido y cacheado un año, con coste real ~0.
  • CSS crítico (descartado tras medir): se probó a inyectar el CSS crítico en línea y cargar style.css de forma asíncrona para bajar el LCP. Medido con Lighthouse, empeoraba el rendimiento (portada 87→80; LCP igual o peor), porque los ~20 KB de crítico en línea inflan el HTML más de lo que ahorra el desbloqueo del render bajo el perfil móvil throttled. Se revirtió: el montaje actual (hoja bloqueante pero comprimida + cacheada, con precarga de fuentes) rinde mejor aquí.

Analítica — eventos GA4 bajo consentimiento

  • Nuevo assets/js/analytics.js (global, no-op si no hay consentimiento) que, por delegación, mide qué herramientas se usan de verdad: copy_result (clic en cualquier botón de copiar), outbound_click (enlaces externos) y eventos explícitos vía data-ga en acciones clave (p. ej. dkim_generate, email_audit). Cada evento lleva el tool (slug de la página). Solo envía si el visitante aceptó cookies. Verificado que dispara los tres tipos y que es no-op sin consentimiento.

Auditoría Lighthouse (portada, herramienta y artículo)

  • Resultados: best-practices 100, SEO 100, accesibilidad 97–100, CLS 0, TBT 0; rendimiento 87–90 (limitado por LCP ~3,5 s en el perfil móvil throttled de Lighthouse). Corregidos los fallos accionables:
  • Accesibilidad — orden de encabezados (portada): las tarjetas de «Últimas Amenazas» eran <h4> bajo un <h2> (salto de nivel); ahora <h3>.
  • Accesibilidad — enlaces por color (artículos): los enlaces dentro del texto ahora van subrayados (WCAG «uso del color»), no solo en cian.
  • 404 en consola: /favicon.ico (y /apple-touch-icon.png) que el navegador pide por convención devolvían 404; añadida una reescritura a los iconos existentes.

Corregido — herramientas que no funcionaban en producción (allow_url_fopen off)

  • El .user.ini de producción desactiva allow_url_fopen, así que el Escáner de seguridad y el Buscador MAC estaban rotos en el VPS (usaban get_headers()/file_get_contents() sobre HTTP). Migrados a cURL vía lib/http.php, que funciona con allow_url_fopen off (verificado con -d allow_url_fopen=0).
  • Nuevo helper ce_http_headers() (petición HEAD por cURL): el Escáner lo usa fijando la conexión a la IP pública ya validada (CURLOPT_RESOLVE) y sin seguir redirecciones → arregla el fallo en prod y cierra del todo el SSRF por rebinding/redirección que señaló la auditoría.
  • Buscador MAC: proxy migrado a ce_http_get(), con validación del formato MAC (solo hex/separadores) y rate-limit por sesión (40/min).

Pulido — CTAs y formularios

  • Formulario de contacto: al enviar, el botón se deshabilita y muestra un spinner con texto «Enviando…» (feedback de envío). Estado :disabled con cursor:progress.
  • Botones de portada (.pb-btn): pequeña elevación al pasar el ratón, para el mismo tacto que el nuevo CTA de contacto.

Corregido — botón de envío del formulario de contacto

  • El botón usaba clases (pb-btn) que no existían en el CSS, así que salía con el estilo por defecto del navegador. Nuevo .contact-submit con el lenguaje visual del sitio: degradado cian→violeta, glow y elevación al pasar el ratón, flecha que desliza y barrido de brillo (respeta reduce-motion).

Privacidad — consentimiento de cookies (RGPD/ePrivacy)

  • Google Analytics ya no se carga sin permiso. Nuevo assets/js/consent.js (origen propio, sin nonce inline) que muestra un banner y solo inyecta GA tras aceptar; si se rechaza no se carga nada de terceros ni se instala ninguna cookie. La elección se guarda en localStorage y se puede cambiar desde el enlace «Cookies» del footer. GA se configura con anonymize_ip y sin señales de publicidad.
  • La política de privacidad se corrige: antes decía «no se usan cookies de terceros» (falso, GA las ponía). Ahora distingue la cookie necesaria (idioma) de la analítica opcional y explica cómo cambiar la decisión.
  • Verificado: sin decisión no se carga GA; Aceptar lo carga y persiste; Rechazar lo bloquea y no vuelve a molestar; el enlace del footer reabre el banner; sin overflow a 375 px ni errores de consola.

Seguridad — auditoría y correcciones

  • Revisión completa de la superficie de ataque (endpoints, cabeceras, CSP, sesión, secretos). El servidor estaba bien protegido (consultas preparadas, validación por lista blanca, salida a hosts fijos, CSP con nonce). Corregido lo detectado:
  • Inyección de cabeceras de correo (medio) en contacto.php: el asunto llegaba a mail() sin limpiar saltos de línea → permitía inyectar Bcc: y usar el formulario como relay de spam. Ahora se eliminan CR/LF y caracteres de control y se acota a 150.
  • SSRF por redirección (medio) en tool-scanner.php: get_headers() seguía redirecciones que esquivaban el filtro anti-IP-interna. Se desactiva el seguimiento de redirecciones.
  • Sin límite de abuso: tool-tls.php (sondas TLS a host:port arbitrarios) ahora tiene rate-limit por sesión (20/min → 429, verificado); tool-takeover.php acota la lista de dominios a 50 por petición (evita consultas DNS ilimitadas).
  • HSTS a nivel de servidor en .htaccess (always set): cubre las respuestas que no pasan por bootstrap.php (api/*, sitemap.php, blog/feed.xml).

CI — validación automática en GitHub Actions

  • Nuevo .github/workflows/ci.yml que en cada push/PR ejecuta: php -l de todos los .php, node --check de todo el JS de primera parte, y el smoke-test de las 166 páginas contra el servidor integrado de PHP. Así no llega nada roto a main.
  • scripts/router.php: enrutador para php -S que emula las reescrituras de .htaccess (URLs limpias→.php, /→index, /dir/→index, sitemap/feed, 404) para poder levantar el sitio sin Apache en CI. No se usa en producción.
  • scripts/smoke-test.php admite SMOKE_SKIP_API=1 para no depender en CI de que las APIs externas estén arriba. Verificado localmente reproduciendo el entorno de CI (con php -S + router y sin db-config.php): 166/166 (la web degrada con elegancia sin base de datos).

SEO — datos estructurados del blog (Article/BlogPosting)

  • Los artículos del blog emiten ahora BlogPosting (titular, datePublished/dateModified, autor, editor, imagen, sección y keywords) y BreadcrumbList (Inicio › Blog › Artículo) en el JSON-LD, además de og:type=article y metas article:published_time/article:author/article:tag.
  • Fecha y metadatos salen de la fuente única lib/posts.php (nuevo helper ce_blog_post()), la misma que alimenta el listado y el RSS, así que no hay que duplicar nada. Verificado: la ficha del artículo sale correcta y el listado del blog sigue siendo website.

PWA — service worker y página offline

  • sw.js a ce-v5: el precache incluye ahora las fuentes latin críticas (para que el texto se vea sin conexión) y varias herramientas 100% cliente (Base64, Hash, Generador de contraseñas, Generador SPF/DMARC/DKIM) además de las que ya estaban.
  • Página offline rediseñada: mantiene el estilo de marca y lista las páginas realmente cacheadas (leyendo la Cache Storage) para que el usuario sepa qué puede abrir sin red, con enlaces directos. Enlaces corregidos a la URL limpia /. Verificado: SW instala v5 (24 recursos precacheados, cachés viejas purgadas) y la lista offline se rellena sola.

Rendimiento — cache de estáticos (arreglo importante), fuentes propias e imágenes

  • Bug de caché corregido: un bloque de cabeceras en .htaccess rotulado «CTF pages» se aplicaba a todo el sitio y metía un segundo Cache-Control: no-store en cada CSS/JS/fuente/imagen, anulando el cache inmutable de un año → todo se re-descargaba en cada visita. Ahora va acotado con <If> a /ctf/ y /missions/; el resto de estáticos ya se sirven con public, max-age=31536000, immutable (verificado). También devuelve el Referrer-Policy global a strict-origin-when-cross-origin (antes forzado a no-referrer en todas partes).
  • Fuentes auto-hospedadas: Inter y JetBrains Mono (subconjuntos latin/latin-ext, OFL) pasan de Google Fonts a assets/fonts/ con @font-face en el CSS. Se eliminan dos peticiones bloqueantes a terceros (googleapis + gstatic), se precargan los subconjuntos latin críticos (cuerpo, título del héroe, monoespaciada) y se retiran esos orígenes del CSP (style-src/font-src más estrictos). Verificado: 0 peticiones a gstatic, sin doble descarga (la URL del preload coincide con la del @font-face).
  • Imágenes: mifoto.jpg (89→47 KB) y hacker_avatar (84→62 KB) ahora se sirven en WebP con <picture> y respaldo JPEG (el navegador elige WebP; el JPEG no se descarga). Todas las <img> llevan width/height (evita CLS), las de bajo del pliegue loading="lazy", y la foto principal de «Sobre mí» fetchpriority="high". El <picture> usa display:contents para no alterar el layout existente.

Accesibilidad — teclado y ARIA

  • Enlace «Saltar al contenido» (oculto hasta recibir foco) al inicio del <body>, con destino tras la barra de navegación. Antes no había forma rápida de saltarse el menú con teclado.
  • Menús desplegables operables por teclado: se añade :focus-within (además del :hover) para que al tabular por «Arsenal», «Laboratorios», etc. se abra el menú. Los disparadores sin página propia (Laboratorios, Aprender) pasan de <a href="#"> a <button> de descripción; todos llevan aria-haspopup.
  • Botón hamburguesa con aria-expanded (sincronizado por JS al abrir/cerrar) y aria-controls.

Robustez — smoke-test y página 404 útil

  • scripts/smoke-test.php: recorre las 167 URLs públicas (mismas reglas que el sitemap) más /api/status y una ruta inexistente, y verifica 200 + ausencia de rastros de error PHP (Fatal/Warning/Notice/Uncaught…). Detectó y se dejó fuera missions/user-api.php (endpoint JSON de la misión IDOR, no una página). Resultado: 167/167 OK. Útil para CI (código de salida != 0 si algo falla).
  • 404 rediseñada: mantiene la estética de terminal y añade buscador (abre la paleta de comandos ⌘K) y sugerencias de herramientas destacadas (desde el catálogo, siempre al día) + enlace al arsenal. Enlaces corregidos a la URL limpia / (antes /index.php) y el REQUEST_URI reflejado ahora se escapa con el helper e().

SEO técnico — canonical, hreflang y robots.txt

  • <link rel="canonical"> autorreferente en todas las páginas (en templates/header.php), apuntando a la URL limpia y sin query. Deduplica de golpe las variantes que antes competían por el mismo contenido: .php (que hacían 301), ?lang=, y parámetros de estado (?in=, ?shared=1, utm_*). La home canoniza a / (colapsa /index).
  • Clúster hreflang (es, en, x-default). La i18n va por sesión/?lang=, así que Googlebot (sin cookie) solo veía el idioma por defecto y la versión inglesa era invisible para el buscador. Ahora cada idioma tiene canonical autorreferente (/ruta para ES, /ruta?lang=en para EN) y el par se enlaza por hreflang → ambas se indexan. Añadido también og:url canónico y og:locale:alternate.
  • robots.txt saneado: eliminados los Allow: /tool-*.php obsoletos (ahora son URLs limpias). Se deja de bloquear CSS/JS (Googlebot los necesita para renderizar y evaluar la página): Allow: /assets/css/, /assets/js/, /assets/img/ por delante del Disallow: /assets/, que sigue protegiendo assets/data/ (logs, caché). Bloqueados además /lib/ y /scripts/.
  • Verificado en vivo: canonical y hreflang correctos en vista ES y EN, home → /, params de estado eliminados del canonical, JSON-LD sigue válido, y sitemap.xml incluye las herramientas nuevas (165 URLs).

Accesibilidad — objetivos táctiles en móvil

  • Los controles del header eran pequeños para tocar en móvil (hamburguesa 30×24, búsqueda 32×32, idioma ×27). En @media (max-width:767px) se amplían: hamburguesa 44×44 y idioma/búsqueda a 44px de alto (el ancho del search se deja natural para que la fila no desborde). Verificado: sin overflow horizontal (375 px), la hamburguesa sigue abriendo el menú, y el arreglo es global (también en páginas de herramienta). style.min.css regenerado.

Rendimiento — compresión + minificación de assets

  • Compresión brotli/gzip en .htaccess (mod_brotli/mod_deflate) para HTML, CSS, JS, JSON, SVG y XML. Reduce ~75-85% los bytes transferidos de texto, sin tocar el código. Verificado en vivo: Content-Encoding: br en el CSS.
  • Minificación de CSS: nuevo scripts/minify.php (sin dependencias) genera style.min.css (131 KB → 91.6 KB, −30%, con calc() y strings intactos). asset() sirve automáticamente el .min si existe y no está obsoleto respecto al fuente (guarda anti-.min-viejo: si editas el CSS sin re-minificar, sirve el original). sw.js precachea el .min (versión → ce-v4).
  • El JS de primera parte se sirve comprimido (gzip/brotli ≈ el mismo ahorro que minificar) en vez de minificarlo con regex, que es arriesgado (ASI, comentarios dentro de strings/regex). Los vendor (mermaid.min.js, html2pdf.min.js) ya venían minificados.
  • Verificado: la home enlaza style.min.css, renderiza igual (871 reglas, navbar sticky, 0 fallos de contraste, sin overflow).

Corregido — terminal (easter-egg) en móvil

  • El terminal del footer aparecía medio abierto al cargar en móvil y su botón >_ no hacía nada. Causas y arreglos:
  • El #term-input tenía autofocus: al cargar, el móvil enfocaba ese campo del footer y hacía scroll hasta revelar el terminal fijo. Quitado autofocus (el JS ya enfoca solo al abrirlo).
  • El widget del terminal vive en el footer global (todas las páginas), pero su JS estaba en missions.js, que solo se cargaba en las páginas de misiones → el botón no respondía en el resto. Ahora missions.js se carga en el footer global (y se quita de los $pageScripts de misiones para no duplicarlo).
  • CSS del terminal: el estado oculto queda inerte (pointer-events:none) y se añade max-height:75vh + padding-bottom: env(safe-area-inset-bottom) para móviles con notch/barra inferior.
  • Verificado: oculto por defecto (fuera de pantalla), abre a pantalla completa inferior y cierra; sin robar foco al cargar; el motor de comandos responde.

Accesibilidad — contraste AA

  • Auditado el contraste real (componiendo alfa sobre el fondo oscuro, como Lighthouse) en portada, catálogo, herramientas y contenido. Corregidos los textos por debajo de 4.5:1:
  • --gray-dark subido de rgba(237,241,246,0.32) (ratio 2.6) a 0.52 (~5.2) y --gray de 0.58 a 0.70 — arreglo global, ya que estas variables se usan en labels/hints de todas las herramientas.
  • Footer: .donate-note, .footer-links y .footer-copy dejan de usar blancos translúcidos (rgba(255,255,255,.18/.25), ratio 1.7–2.2) y pasan a las variables ya compatibles; .rss-source deja #666.
  • CTA del reto diario: fondo #ff2a2a→#dc2626 para que el texto blanco pase AA (era 3.5).
  • Enlaces de texto de LinkedIn (#0077b5/#0a66c2, ratio ~4.0) → #4aa3e0 (~6.8); los botones (blanco sobre azul) ya pasaban.
  • aria-label en el input del terminal (#term-input), que no tenía nombre accesible.
  • Verificado: portada, /tools, tool-tls y sobre-mi con 0 fallos de contraste; sin enlaces/botones sin nombre.

Cambiado — donaciones más discretas (profesionalidad)

  • Quitado el botón ♥ «Apóyame» de la barra de navegación (escritorio y móvil) y la tarjeta de donación destacada de la portada. El acceso se mantiene, pero discreto en el footer con copy más suave: «☕ Invítame a un café» → enlaza directamente al PayPal. Menos «proyecto que pide dinero» y más portfolio/producto cuidado, sin perder la opción de apoyar.

Rendimiento — split de main.js (303 KB → 12 KB) ✅ completado

  • main.js cargaba 303 KB en todas las páginas con código de ~15 páginas distintas. Partido por completo en módulos por página (mecanismo $pageScripts), dejando main.js como núcleo global de ~12 KB (navbar/scroll, copia de bloques de código, barra de lectura, TOC, filtros del índice, fade-in y menú móvil) — lo único que de verdad se usa en todas las páginas.
  • Módulos extraídos y cargados solo en su(s) página(s): labs-bundle.js (100 KB — Sentinel, Threat Map, Sandbox, C2), soc-arsenal.js (61 KB, con su BD bilingüe), report-generator.js (18 KB), ir-tabletop.js (18 KB), arch-bastion.js (Arch Designer + OS Bastioning + init de mermaid), dashboards.js (escáner perimetral + simulador SOC + feed CVE/noticias), missions.js (misiones/CTF: XP/HUD, spoilers, progreso; incluye el terminal easter-egg), threat-hunting.js (SIEM), mitre-mapper.js (9 KB), architecture.js (partículas), profile.js (3 KB).
  • tool-threat-map.php (que no incluye footer) recibe su bundle con un <script> directo; el resto vía $pageScripts. En report-generator.php se conserva html2pdf.min.js junto al módulo.
  • Verificado en vivo: las 7 páginas afectadas funcionan (Arsenal 244 entradas + filtro, MITRE 1139 celdas, IR tabletop, report + PDF, profile, C2, threat-map canvas, sandbox, sentinel/KQL) sin errores de consola; la portada carga solo main.js (ningún módulo de lab). Núcleo global intacto.
  • Con esto, una página normal (portada, herramientas, contenido) pasa de arrastrar ~400 KB de JS (tools.js 100 KB + main.js 303 KB) a ~15 KB de núcleo. Cada lab/simulador carga solo su módulo. Todas las páginas afectadas verificadas en navegador sin errores de consola.

Rendimiento — fin del monolito tools.js

  • Eliminado assets/js/tools.js (~100 KB) que cargaban las 29 páginas de herramienta. Su lógica compartida (navegación del selector + copiado al portapapeles, ahora window.copyToClipboard) vive en un core.js mínimo (~2 KB), y cada herramienta que aún tenía su código dentro del monolito pasa a su propio módulo: hash-gen.js, base64.js, chmod.js, regex.js, mac.js, headers.js, httpbuilder.js, cloud.js, takeover.js, recon.js, ssh.js, ports.js.
  • Las ~16 páginas que ya tenían módulo propio (cidr, jwt, cve, dns, ip, url, passcheck…) soltaron los ~100 KB de peso muerto que arrastraban además de su módulo.
  • Impacto por página: de 100 KB fijos a ~3,5–7 KB en la mayoría (−93 a −96 %); las dos más densas (headers 17 KB, cloud 23 KB) siguen muy por debajo del monolito. Menos JS que descargar, parsear y ejecutar = mejor TBT/LCP.
  • Limpieza: eliminado código muerto que quedaba en el monolito (bloque viejo de reverse shell con rs-ip, ya sustituido por revshell.js; y el bloque PDF/donación que en realidad maneja osint.js). sw.js precachea core.js en vez de tools.js (versión → ce-v3). agent-core.js pasa a cargarse con defer.
  • Verificado en vivo: las 12 herramientas extraídas funcionan (base64, chmod, hash, mac, regex, headers→nota, httpbuilder→curl, ssh→análisis, cloud, takeover, recon, ports) y el selector navega, sin errores de consola y sin tools.js en ninguna página.

Cambiado — ampliación de herramientas (ronda 4)

  • Analizador de Cron (tool-cron.php + assets/js/cron.js): reescrito. Parser correcto que expande cada campo admitiendo pasos, rangos, rangos con paso (8-18/2), listas, nombres de mes/día (jan-mar, mon) y atajos (@daily, @hourly, @reboot…). Corrige un bug real: la semántica de cron es OR entre día-del-mes y día-de-semana cuando ambos están restringidos (el código anterior usaba AND, dando resultados vacíos o erróneos). Descripción en lenguaje humano, próximas 6 ejecuciones con tiempo relativo en zona horaria local y validación con mensaje de campo inválido. Nuevo: auditoría de seguridad del comando con severidad (rm -rf, curl|sh, Base64→shell, reverse shells /dev/tcp, ficheros sensibles, rutas temporales, IP en crudo, frecuencia excesiva). Verificado: 0 0 1 * 1 lista lunes y el día 1 (OR), curl|sh → 3 avisos, campo 99 → error.
  • Generador de Wordlist (tool-wordlist.php + assets/js/wordlist.js): reescrito. Además de las mutaciones básicas, ahora tiene modos de mayúsculas combinables (minúsculas, Capitalizada, MAYÚSCULAS, aLtErNa, tal cual), leetspeak básico y agresivo, prefijos/sufijos con separadores (_ - . @) aplicables al principio o al final, combinación de dos palabras (admin_empresa) y un filtro por política de contraseña (longitud mín./máx. y exigir dígito/mayúscula/símbolo) que descarta candidatos inválidos antes de generarlos. Recuento y tamaño en vivo, con tope de 250 000 candidatos avisado (sin recorte silencioso), copia y descarga .txt. Verificado: exige-dígito 133→96 (todos con dígito), combinación real de dos palabras, sin overflow a 375 px.
  • WAF Bypass (tool-waf.php + assets/js/waf-tools.js): pasa de biblioteca estática a laboratorio de evasión. Nuevo Mutador: pegas cualquier payload y genera variantes de evasión aplicando transformaciones reales (cambio de mayúsculas, comentarios en línea //, comentario versionado /*!50000*/, partir palabra clave, espacios alternativos, operadores equivalentes &&/||, literales a hex 0x…, URL/doble-URL/URL-total, entidades HTML dec/hex, escape Unicode JS), cada una con la explicación de por qué salta el filtro y con autodetección de contexto (XSS/SQLi/genérico). Se conserva la Biblioteca de 158 payloads en 8 categorías (filtro, copia y descarga). Verificado: SQLi → 11 variantes, XSS → 9 (alert\1\, tab en etiqueta…), sin overflow a 375 px.
  • Generador de Reverse Shell (tool-revshell.php + assets/js/revshell.js): reescrito a fondo. Tres modos —reverse shell, bind shell y MSFVenom— con 34 payloads en 30+ lenguajes (Bash 196/read-line/UDP, nc/ncat/BusyBox, OpenSSL TLS, Python 2/3, PHP en cinco variantes, Perl, Ruby, Go, Node, Java, Groovy, Lua, Awk, Telnet, socat, PowerShell y PowerShell Base64). Selector de shell (/bin/bash, sh, zsh…), codificación Base64 / URL / doble-URL para evasión de WAF, listener conmutable (nc, rlwrap, ncat, ncat-TLS, socat con TTY, pwncat, msfconsole multi/handler), validación de IP/puerto y chuleta de estabilización de TTY. Verificado: Base64 decodifica al comando exacto, PS Base64 en UTF-16LE, msfvenom + handler emparejados, sin overflow a 375 px.

Cambiado — ampliación de herramientas (ronda 3)

  • Generador de Contraseñas (tool-passgen.php + assets/js/passgen.js): tres modos con crypto.getRandomValues (nunca Math.random) y entropía en vivo — aleatoria (alfabeto configurable, sin ambiguos opcional, garantiza un carácter de cada conjunto), frase de paso diceware (~471 palabras es/en, 6 palabras + número ≈ 60 bits) y pronunciable. Con generación en lote de 12. Verificado: aleatoria 103 bits, frase 60 bits.
  • ¿Cuál es mi IP? (tool-ip.php + assets/js/ip-privacy.js): pasa a chequeo de privacidad. Geolocalización + ISP (ipwho.is), huella del navegador (UA, motor, pantalla, CPU/RAM, GPU WebGL, hash de canvas, idiomas, DNT), prueba de fuga WebRTC y veredicto que avisa del desajuste de zona horaria navegador↔IP (señal de VPN). Verificado: detecta la IP pública real por WebRTC.
  • URL Encoder/Decoder (tool-url.php + assets/js/url-tools.js): además de codificar/decodificar (con desmontado de capas múltiples), analizador de URL que desglosa esquema/host/puerto/ruta/parámetros en tabla, detecta base64 oculto en los parámetros y avisa de HTTP, Punycode (homográfico), credenciales embebidas y doble codificación. Verificado con un enlace de phishing: descubre redir → https://evil.com/phish.
  • Calculadora CIDR (tool-cidr.php + assets/js/cidr-tools.js): IPv4 e IPv6 con BigInt (red, broadcast, máscara, wildcard, rango, hosts, ámbito RFC1918/ULA/CGNAT…), división en subredes y comprobación de si una IP cae dentro de un rango. Verificado: /26 → 4 subredes /28, IPv6 /48 con 1,2×10²⁴ direcciones.
  • Los bloques de estas cuatro salieron de tools.js a ficheros propios; tools.js baja a ~139 KB.

Cambiado — ampliación de herramientas (ronda 2)

  • Buscador CVE (tool-cve.php + assets/js/cve-finder.js): deja de ser un proxy fino a NVD. El servidor enriquece cada CVE con EPSS + KEV (reutilizando lib/vuln-intel.php) y la ficha muestra CVSS con vector, EPSS, percentil, KEV con fecha límite de CISA, CWE y enlaces a Exploit-DB/GitHub/NVD. Acepta CVE-ID exacto o palabra clave, ordena por riesgo real y admite ?q= para compartir búsquedas. Verificado: Log4Shell → CVSS 10, EPSS 99,9%+, KEV+ransomware.
  • Analizador de Logs (tool-loganalyzer.php + assets/js/log-analyzer.js): reescrito. 9 firmas de ataque (SQLi, XSS, LFI, RCE, SSRF, XXE, SSTI, fuerza bruta, webshell), decodificación doble para evasión, top de IPs atacantes y rutas, detección de escáneres por User-Agent, sparkline de actividad por minuto, extracción de IOCs y generación de regla Sigma/KQL del patrón dominante (ponderado por severidad) enlazando con Sigma Forge.
  • Multi Decoder (tool-multidecode.php + assets/js/multi-decode.js): «CyberChef ligero». 17 códecs (Base64/32/58, Hex, URL, HTML, Unicode, binario, decimal, ROT13/47, Atbash, Morse, JWT, gzip/zlib, invertir), modo Magia que deshace hasta 12 capas puntuando por texto con sentido y forma de flag, receta manual encadenable con deshacer, y fuerza bruta de ROT-n (×25) y XOR de un byte. Verificado: flag en 2 capas y flag{rot13_is_weak} como primer acierto del ROT brute.
  • DNS Lookup (tool-dns.php + assets/js/dns-tools.js): además de los registros (con «ALL» en paralelo y flag DNSSEC), nuevo modo postura de seguridad del correo que analiza SPF (política ~all/-all y límite de lookups), DMARC (p=none/quarantine/reject y rua), DKIM (selectores comunes), DNSSEC y MX, con nota A–F. Enlaza con el Triage de Email.
  • Los bloques de estas cuatro salieron de tools.js a ficheros propios; tools.js baja de ~183 KB (inicio de sesión) a ~147 KB.

Corregido

  • El endpoint del Buscador CVE se sirve por URL limpia (/tool-cve), no .php, para no perder el POST/GET en la redirección 301 del .htaccess.

Cambiado — efecto del título del hero

  • Sustituida la aberración cromática permanente (RGB rojo/cian temblando sin parar, con clip:rect() obsoleto) por dos efectos que encajan con el estilo nuevo: aurora en reposo —degradado cian→violeta que recorre despacio «Mundo Digital», a juego con la malla del fondo— y el glitch solo al pasar el ratón por el título. Ambos respetan «reducir movimiento» (media query y toggle del sitio).

Cambiado — herramientas de más uso, a fondo

  • Fortaleza de Contraseña (assets/js/passcheck.js): reescrita. Deja de contar requisitos —el método que daba verde a «P@ssw0rd123»— y mide entropía real descontando patrones que un atacante prueba primero (recorridos de teclado, sustituciones l33t, fechas, secuencias, repeticiones, palabras comunes). Añade tiempo de crack en tres escenarios (online, bcrypt, granja de GPUs) y comprobación contra Have I Been Pwned por k-anonymity: solo salen del navegador los 5 primeros caracteres del hash SHA-1, nunca la contraseña. Verificado: «password» → 52.372.427 filtraciones.
  • Decodificador de JWT (assets/js/jwt-analyze.js): además de descodificar, audita el token — alg=none, expiración vencida o eterna, nbf/iat, kid inyectable, datos sensibles y claims de autorización — y prueba secretos débiles HS256/384/512 calculando el HMAC en el navegador contra un diccionario. Explica los claims registrados y traduce las fechas.
  • Analizador de Hashes (assets/js/hash-id.js): identifica por prefijo y por longitud+alfabeto con candidatos ordenados, distingue casos ambiguos con pistas (MD5 vs NTLM vs LM), da el modo exacto de Hashcat (-m) y el formato de John para cada uno, marca si el algoritmo es rápido/lento, y mantiene el crack por diccionario en el navegador para SHA-1/256/384/512 (con aviso honesto de que MD5/NTLM no los calcula el navegador).
  • Cabeceras HTTP (assets/js/tools.js): añadido análisis fino del CSP (detecta unsafe-inline/unsafe-eval, comodines en las fuentes de script y ausencia de object-src/base-uri/frame-ancestors) y dos cabeceras cross-origin modernas (COOP y CORP).
  • bootstrap.php: api.pwnedpasswords.com añadido a connect-src en la CSP, necesario para la comprobación de filtraciones.
  • Los bloques de estas herramientas se sacaron de assets/js/tools.js a ficheros propios; tools.js baja de ~183 KB a ~169 KB.

Corregido

  • El botón de búsqueda global desbordaba la barra en móvil (~360-375 px): en móvil se colapsa a solo la lupa y se ajusta la separación del nav.

Cambiado — nueva marca «el bisel»

  • Logotipo nuevo (assets/img/logo-bisel.svg): escudo con mordeduras hexagonales en los flancos, resuelto en dos planos de luz —el izquierdo iluminado, el derecho en sombra— para que se lea como una pieza tallada. Sustituye al escudo con check anterior en la cabecera, el pie, el favicon y la PWA.
  • Juego de iconos completo generado desde la misma geometría del SVG: favicon-16/32.png, apple-touch-icon.png (180 px, sin transparencia, que iOS no la admite), icon-192/512.png con fondo y esquinas redondeadas, y icon-maskable-512.png con margen suficiente para sobrevivir al recorte circular de Android.
  • Portada Open Graph rehecha (og-cover.png, 1200×630) con la marca nueva, la malla del sitio y el titular «Encontrar el fallo es fácil. Saber cuál importa, no.». Es la imagen que se ve al compartir en LinkedIn.
  • theme-color y el manifest sincronizados con el fondo nuevo (#0b0c10).

Cambiado — identidad visual «J + M»

  • Nueva paleta base, aplicada retocando los valores de las variables que ya existían en :root, así que las 60 páginas heredan el aspecto nuevo sin tocarlas una a una: fondo #0b0c10, cian #2dd4e8 (más suave que el #00ffff anterior), violeta de apoyo #8b7cff, radio 14 px y una definición única de elevación, filo de luz y anillo de foco.
  • Malla de fondo en movimiento: tres manchas de color muy desenfocadas que se desplazan en ciclos de 19, 23 y 27 segundos sin sincronizar, al 35 % de opacidad. En móvil se reduce el desenfoque y se detienen, porque un blur de 64 px es caro; también se detienen con «reducir movimiento».
  • Las tarjetas pasan a vidrio con filo de luz en el borde superior, y el foco de teclado es visible y coherente en todo lo que se puede teclear o pulsar.

Corregido

  • La primera versión de la malla subía el contenido con una regla body > * que, por especificidad, machacaba el position:fixed del terminal flotante, del modal de donación y de la propia paleta de comandos. Ahora el fondo vive en <html> y la malla va detrás con z-index:-1, sin tocar ningún otro elemento.

Corregido — PWA

  • El service worker no precacheaba las herramientas: se prometía análisis sin conexión y sin red el script no llegaba a cargar. Ahora precachea 13 recursos, incluidos tool-eml, tool-sigma-forge, el catálogo y sus JS, verificado con cero errores de descarga.
  • El precache usaba un addAll() cuyo fallo quedaba silenciado por un catch vacío: un solo recurso caído dejaba la caché entera vacía sin avisar. Ahora cada recurso se cachea por separado.
  • start_url apuntaba a /index.php, que el .htaccess redirige con un 301 a la URL limpia: cada arranque de la app se comía una redirección. Ahora es /.
  • Añadidos id y display_override al manifest para que la identidad de la app sea estable entre actualizaciones.

Cambiado — reestructuración del contenido

  • Catálogo único en lib/catalog.php: 50 elementos declarados en un solo sitio. De ahí se generan el menú de escritorio, el de móvil, el selector de herramientas, el catálogo, las relacionadas y la prioridad del sitemap. Añadir una herramienta pasa de tocar 34 ficheros a tocar 1.
  • Clasificación por lo que el visitante viene a hacer, en 5 familias (Defensa y SOC · Gestión de vulnerabilidades · Ofensiva y pentesting · OSINT y reconocimiento · Cripto y datos) más Laboratorios, Aprender y Perfil. Las 7 aplicaciones interactivas que estaban enterradas entre los writeups de projects/ aparecen ya en Laboratorios.
  • templates/tool-switcher.php: un único selector con <optgroup> por familia sustituye a las 33 copias a mano que nunca coincidían entre sí (993 líneas eliminadas, 860 <option> menos que mantener).
  • Menú de escritorio y menú móvil se generan del mismo array, así que dejan de poder divergir. Antes tenían taxonomías distintas.
  • tools.php deja de ser un callejón sin salida («> Esperando selección…») y pasa a ser el catálogo del Arsenal, con buscador que ignora acentos, filtros por familia y enlace directo por ancla (/tools#osint).
  • templates/related.php: bloque «Sigue por aquí» al pie de 42 páginas, con sugerencias de la misma familia.
  • El mega del Arsenal se ancla al viewport y se adapta de 5 a 3 columnas; los enlaces destacados usan la clase .is-hot en vez de estilos en línea repetidos.
  • sitemap.php toma la prioridad del catálogo en vez de deducirla del prefijo del nombre de fichero.
  • 5 herramientas y 1 simulador que no se podían alcanzar desde ningún menú (tool-chmod, tool-cron, tool-passcheck, tool-regex, tool-url, soc-simulator) quedan enlazados.

Corregido

  • El menú móvil no se abría. main.js y templates/header.php registraban cada uno un manejador de clic sobre el mismo botón y alternaban las mismas clases: uno abría el menú y el otro lo cerraba en el mismo clic. Desde que main.js se carga con defer se registra antes, así que el stopImmediatePropagation() del header llegaba tarde. Eliminado el manejador duplicado de main.js.

Cambiado

  • .gitignore excluye assets/data/cvss-cache.json: lo llena el uso de la herramienta, no es contenido del repo.
  • «¿Qué parcheo primero?» muestra el vector CVSS de cada CVE y advierte de que la puntuación es la que publica el CNA, que a veces no coincide con NVD (el registro de Zerologon publica un 5.5 con AV:L). Por eso el CVSS pesa solo 15 de 100 puntos: con EPSS y KEV, Zerologon sigue saliendo P1.

Corregido

  • curl_init() no existe en todos los binarios de PHP (el php del PATH en el VPS de Plesk no trae la extensión), lo que mataba scripts/sync-kev.php con un fatal. Nuevo lib/http.php: cliente HTTP con cURL cuando está disponible y respaldo por envoltorios de streams cuando no, en paralelo o en serie según el caso. sync-kev.php y api/patch-priority.php ya no dependen de cURL, y el script indica qué binario usar si tampoco hay allow_url_fopen.
  • Este CHANGELOG.md. A partir de ahora se actualiza en cada cambio.

Pendiente en el working tree

  • assets/data/rss-cache.json regenerado (caché del feed, sin cambios de código).

---

Corregido

  • El Centro de Inteligencia y el Simulador SOC no cargaban por main.js sin defer.
  • Desbordamiento horizontal en móvil.
  • Sitemap pasa a ser dinámico (sitemap.php).
  • Onboarding del ranking.

Eliminado

  • Soporte de GitHub en el perfil (se descarta su uso).

---

Eliminado

  • Modo claro. Se revierte y queda solo el tema oscuro de marca.

---

Corregido

  • templates/footer.php provocaba un fatal cuando una página machacaba la variable $t.

---

Corregido

  • Generador de CV devolvía 500.
  • Enlaces rotos y riesgo de DoS en los scripts ETL.
  • Optimización de imágenes.

---

Seguridad

  • Contenido el path traversal en el módulo CTF.
  • Bloqueado el SSRF hacia direcciones internas.

---