"""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": ,
"final_url": ,
"title": ,
"status_code": None, # CDP no expone el status del documento principal
"headers": {}, # CDP no expone response headers sin Network domain
"cookies": [],
"html": ,
"html_len": ,
"rendered": True, # marca que el html es post-JS
"raw": ,
}
En error::
{"status": "error", "error": , "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))