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:
@@ -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))
|
||||
Reference in New Issue
Block a user