"""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))