feat(recon): modo CDP en fingerprint_web_stack para detectar SPAs

Añade fetch_http_fingerprint_cdp_py_browser (domain browser): recoge el HTML
renderizado tras ejecutar JavaScript usando un Chrome remoto via CDP, componiendo
cdp_open_url_and_wait + cdp_eval. Devuelve la misma estructura que el fetch
estático para que detect_web_tech lo consuma sin cambios.

Integra use_cdp en el pipeline fingerprint_web_stack (v1.1.0): combina los headers
reales del fetch estático con el HTML post-JS del CDP. Detecta frameworks de SPA
(React/Vue/Angular/Next) que el fetch estático no ve porque montan el DOM en
runtime. Si no hay Chrome en cdp_port, degrada al fetch estático con un warning
(no rompe). cdp_port=9333 (Chrome aislado) recomendado para terceros, 9222 diario.

Verificado en vivo (Chrome 9333): sobre una SPA cuyo marcador de framework solo
aparece tras ejecutar JS, el estático detecta solo nginx; con use_cdp=True detecta
además Next.js, React y Node.js.

Tests: 48 verdes (error path sin Chrome + happy path mockeado + degradación).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:31:28 +02:00
parent 935008ec3f
commit 1430039688
7 changed files with 649 additions and 40 deletions
@@ -0,0 +1,206 @@
"""Fingerprint web con HTML RENDERIZADO (post-JS) via Chrome DevTools Protocol.
Funcion IMPURA: usa un Chrome con remote debugging para navegar a una URL,
esperar a que el JavaScript de la pagina monte el DOM, y recoger el HTML ya
renderizado (mas titulo, URL final y nombres de cookie). Devuelve la MISMA
estructura que `fetch_http_fingerprint_py_cybersecurity` para que el matcher de
firmas `detect_web_tech_py_cybersecurity` la consuma SIN cambios.
Por que existe: el fetch estatico (`fetch_http_fingerprint`) hace un GET con
urllib y NO ejecuta JavaScript. Una SPA (React/Vue/Angular/Next con HTML inicial
casi vacio) monta su framework en runtime, asi que el estatico no ve el stack.
Esta funcion recoge el HTML DESPUES de que el JS pinte, de modo que el matcher
detecta el framework igual que un Wappalyzer dinamico.
Compone DOS funciones del registry (no reescribe transporte CDP):
1. `cdp_open_url_and_wait` (pipeline) — crea tab nuevo en Chrome remoto, navega
y espera `Page.loadEventFired`. Devuelve el tab_id.
2. `cdp_eval` (browser) — evalua JS en la pestana cuyo URL contiene un substring.
SEGURIDAD: en `cookies` solo se guardan los NOMBRES, jamas los valores (son
tokens de sesion sensibles). `document.cookie` ademas NO ve cookies httponly:
esas (y los headers de respuesta) vienen mejor del fetch estatico.
Devuelve SIEMPRE un dict (estilo del grupo recon): nunca lanza excepciones.
"""
import json
import os
import sys
import time
import urllib.parse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from browser.cdp_eval import cdp_eval
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
def _cookie_names(cookie_str: str) -> list[str]:
"""Extrae SOLO los nombres de cookie de un `document.cookie` (nunca valores).
`document.cookie` viene como ``"a=1; b=2; c=3"``. Partimos por ';' y nos
quedamos con lo anterior al primer '=' de cada par. Deduplica en orden.
"""
out: list[str] = []
seen: set[str] = set()
for pair in (cookie_str or "").split(";"):
pair = pair.strip()
if not pair:
continue
name = pair.split("=", 1)[0].strip()
if name and name not in seen:
seen.add(name)
out.append(name)
return out
def fetch_http_fingerprint_cdp(
url: str,
*,
port: int = 9222,
wait_render_s: float = 2.0,
timeout_s: float = 30.0,
close_tab: bool = True,
) -> dict:
"""Recoge el HTML renderizado (post-JS) de una URL via CDP para fingerprinting.
Funcion IMPURA: necesita un Chrome con remote debugging escuchando en `port`.
Navega con `cdp_open_url_and_wait`, espera `wait_render_s` para que la SPA
pinte el DOM, y recoge senales con `cdp_eval`. Nunca lanza: cualquier fallo
(Chrome no responde, tab no abre, eval con error) devuelve
``{"status": "error", ...}``.
La estructura de salida es COMPATIBLE con `fetch_http_fingerprint` y
`detect_web_tech`: `status_code` y `headers` quedan a None/vacios (CDP no
expone la capa de red sin el dominio Network); esta funcion aporta el `html`
RENDERIZADO, que es justo lo que el matcher de firmas necesita para una SPA.
Args:
url: URL objetivo del fingerprint.
port: Puerto de remote debugging del Chrome a usar. Default 9222
(navegador diario, ya activado global). Para AISLAMIENTO (recon de
terceros sin mezclar tu sesion personal) apunta a 9333 (el Chrome
aislado del browser_mcp).
wait_render_s: Segundos extra de espera tras el load para que el JS de la
SPA pinte el DOM (el load event NO garantiza render completo). Default 2.0.
timeout_s: Timeout de la navegacion en segundos. Default 30.0.
close_tab: Si True, cierra el tab al terminar (best-effort via
`window.close()`) para no dejar pestanas abiertas en el navegador.
Default True.
Returns:
dict. En exito::
{
"status": "ok",
"url": <url solicitada>,
"final_url": <location.href tras redirects client-side>,
"title": <document.title o None>,
"status_code": None, # CDP no expone el status del documento principal
"headers": {}, # CDP no expone response headers sin Network domain
"cookies": [<nombres de cookie no-httponly>],
"html": <HTML renderizado (post-JS)>,
"html_len": <len del html>,
"rendered": True, # marca que el html es post-JS
"raw": <bloque legible de evidencia>,
}
En error::
{"status": "error", "error": <mensaje claro>, "url": <url>}
"""
if not url or not url.strip():
return {"status": "error", "error": "fetch_http_fingerprint_cdp: url vacia", "url": url}
# Substring para elegir el target correcto en cdp_eval. El hostname es el
# fragmento mas estable de la URL (sobrevive a query strings y fragments).
try:
substr = urllib.parse.urlparse(url).hostname or url
except Exception: # noqa: BLE001 — URL malformada, caer al url completo
substr = url
# 1. Navegar: crea tab nuevo en el Chrome remoto y espera el load event.
try:
cdp_open_url_and_wait(port, url, int(timeout_s))
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
msg = str(e)
# Mensaje claro para el caso mas comun: no hay Chrome escuchando.
if "no se pudo crear tab" in msg or "URLError" in msg or "Connection refused" in msg:
msg = f"no hay Chrome en el puerto {port} (¿remote debugging activo?): {e}"
return {"status": "error", "error": f"fetch_http_fingerprint_cdp: {msg}", "url": url}
# 2. Esperar el render del JS (el load event no garantiza DOM pintado en SPAs).
if wait_render_s > 0:
time.sleep(wait_render_s)
# 3. Recoger senales con un solo eval (un objeto JSON con todo).
expr = (
"JSON.stringify({"
"html: document.documentElement.outerHTML,"
"title: document.title,"
"href: location.href,"
"cookie: document.cookie"
"})"
)
r = cdp_eval(expr, port=port, target_url_substr=substr, timeout_s=max(10.0, timeout_s))
# 4. (best-effort) cerrar el tab para no dejar basura en el navegador.
if close_tab:
try:
cdp_eval("window.close()", port=port, target_url_substr=substr, timeout_s=5.0)
except Exception: # noqa: BLE001 — cierre best-effort, no afecta al resultado
pass
if not r.get("ok"):
err = r.get("error") or "eval CDP fallo sin mensaje"
return {
"status": "error",
"error": f"fetch_http_fingerprint_cdp: no se pudo evaluar JS ({err})",
"url": url,
}
raw_value = r.get("value")
try:
data = json.loads(raw_value) if isinstance(raw_value, str) else (raw_value or {})
except Exception: # noqa: BLE001 — JSON malformado del eval
return {
"status": "error",
"error": "fetch_http_fingerprint_cdp: respuesta del eval no es JSON valido",
"url": url,
}
html = data.get("html") or ""
title = data.get("title") or None
final_url = data.get("href") or r.get("target_url") or url
cookies = _cookie_names(data.get("cookie") or "")
raw = (
f"CDP fingerprint {url}\n"
f"final_url: {final_url}\n"
f"title: {title}\n"
f"html_len: {len(html)}"
)
return {
"status": "ok",
"url": url,
"final_url": final_url,
"title": title,
"status_code": None,
"headers": {},
"cookies": cookies,
"html": html,
"html_len": len(html),
"rendered": True,
"raw": raw,
}
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else "https://react.dev/"
dbg_port = int(sys.argv[2]) if len(sys.argv) > 2 else 9222
out = fetch_http_fingerprint_cdp(target, port=dbg_port)
# No volcar el html entero por stdout: solo el resumen.
summary = {k: v for k, v in out.items() if k != "html"}
print(json.dumps(summary, ensure_ascii=False, indent=2))