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,84 @@
---
name: fetch_http_fingerprint_cdp
kind: function
lang: py
domain: browser
version: "1.0.0"
purity: impure
signature: "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"
description: "Fingerprint web con HTML RENDERIZADO tras ejecutar JavaScript via Chrome DevTools Protocol (CDP). Navega con un Chrome remoto, espera a que la SPA monte el DOM y recoge el HTML post-JS, titulo, URL final y nombres de cookie. Detecta frameworks que el fetch estatico NO ve: React, Vue, Angular, Next, Svelte montados en runtime. Wappalyzer dinamico: devuelve la MISMA estructura que fetch_http_fingerprint para que detect_web_tech la consuma sin cambios. Recon web de SPAs / single-page applications con HTML inicial vacio."
tags: [recon, web-recon, browser, cdp, fingerprint, spa, wappalyzer, javascript, react, vue, angular]
uses_functions: ["cdp_open_url_and_wait_py_pipelines", "cdp_eval_py_browser"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: url
desc: "URL objetivo del fingerprint (sitio a inspeccionar)."
- name: port
desc: "Puerto de remote debugging del Chrome a usar. Default 9222 (navegador diario, activado global). Para aislamiento de recon de terceros, apuntar a 9333 (Chrome aislado del browser_mcp)."
- name: wait_render_s
desc: "Segundos extra de espera tras el load event para que el JS de la SPA pinte el DOM (el load NO garantiza render completo). Default 2.0."
- name: timeout_s
desc: "Timeout de la navegacion en segundos. Default 30.0."
- name: close_tab
desc: "Si True, cierra el tab al terminar (best-effort via window.close()) para no dejar pestanas abiertas. Default True."
output: "dict siempre (nunca lanza). En exito: {status:'ok', url, final_url, title, status_code:None, headers:{}, cookies:[solo nombres no-httponly], html:<RENDERIZADO post-JS>, html_len, rendered:True, raw}. En error: {status:'error', error:<mensaje claro>, url}. status_code/headers quedan vacios porque CDP no expone la capa de red; esta funcion aporta el HTML renderizado, que es lo que detect_web_tech necesita para una SPA."
tested: true
tests: ["test_sin_chrome_devuelve_error_sin_lanzar", "test_url_vacia_devuelve_error", "test_happy_path_monkeypatch", "test_happy_path_eval_falla_devuelve_error"]
test_file_path: "python/functions/browser/fetch_http_fingerprint_cdp_test.py"
file_path: "python/functions/browser/fetch_http_fingerprint_cdp.py"
---
## Ejemplo
```python
import sys, os, json
sys.path.insert(0, os.path.join("python", "functions"))
from browser.fetch_http_fingerprint_cdp import fetch_http_fingerprint_cdp
from cybersecurity.detect_web_tech import detect_web_tech
# Recoge el HTML RENDERIZADO (post-JS) de una SPA via el Chrome diario (9222).
res = fetch_http_fingerprint_cdp("https://react.dev/", port=9222)
if res["status"] == "ok":
# detect_web_tech (PURA) consume las mismas senales que fetch_http_fingerprint.
tech = detect_web_tech(
res["headers"], # {} con CDP — usa el fetch estatico para headers
html=res["html"], # el HTML RENDERIZADO post-JS: aqui esta la clave
cookies=res["cookies"], # solo nombres
final_url=res["final_url"],
)
print(json.dumps(tech, ensure_ascii=False, indent=2))
else:
print("error:", res["error"])
```
## Cuando usarla
Cuando el fetch estatico (`fetch_http_fingerprint`) NO detecta el framework porque
el sitio es una SPA que monta el DOM con JavaScript (HTML inicial casi vacio:
`<div id="root">` o `<div id="__next">` sin contenido). Esta funcion recoge el HTML
DESPUES de que el JS pinte, de modo que `detect_web_tech` ve React / Vue / Angular /
Next igual que un Wappalyzer dinamico. Requiere un Chrome con remote debugging.
Combina ambas capas para fingerprint completo: estatico para headers + status +
cookies httponly; CDP para el HTML renderizado.
## Gotchas
- **Requiere un Chrome con remote debugging** escuchando en `port`: 9222 (navegador
diario, ya activado global) o 9333 (Chrome aislado del browser_mcp). Sin Chrome
vivo devuelve `{status:"error", error:"no hay Chrome en el puerto N (¿remote debugging activo?)"}` — no lanza.
- **Abre un tab en ESE navegador.** Con `port=9222` mezcla la sesion de tu navegador
PERSONAL (cookies de tu sesion, historial). Para recon de TERCEROS prefiere
`port=9333` (aislado) para no contaminar ni filtrar tu sesion.
- **`document.cookie` NO ve cookies httponly** (las de sesion casi siempre lo son):
esas y los headers de respuesta vienen mejor del fetch estatico `fetch_http_fingerprint`.
- **`headers` y `status_code` quedan vacios/None**: CDP no expone la capa de red sin
el dominio Network. Esta funcion aporta el HTML renderizado, no la red. Si necesitas
el status real o headers, usa el fetch estatico en paralelo.
- **`wait_render_s` puede ser insuficiente** para SPAs lentas (mucho data-fetching tras
el load). Si el `html` sale incompleto, sube `wait_render_s` (ej. 4.0-6.0).
- **Respeta scope y autorizacion legal**: solo inspecciona sitios que tengas permiso
para analizar.
@@ -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))
@@ -0,0 +1,88 @@
"""Tests para fetch_http_fingerprint_cdp.
No hay Chrome en el entorno de test/CI. Se cubren:
- El error path REAL: sin Chrome escuchando -> {status:"error"} sin lanzar.
- El happy path por composicion: monkeypatch de cdp_open_url_and_wait + cdp_eval
para validar la orquestacion (estructura, html renderizado, cookies solo nombres)
sin Chrome real.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import browser.fetch_http_fingerprint_cdp as mod
from browser.fetch_http_fingerprint_cdp import fetch_http_fingerprint_cdp
def test_sin_chrome_devuelve_error_sin_lanzar():
# Puerto donde no hay Chrome -> degradacion limpia, nunca excepcion.
res = fetch_http_fingerprint_cdp("http://127.0.0.1:1/", port=1, timeout_s=2)
assert res["status"] == "error"
assert "error" in res and res["error"]
assert res["url"] == "http://127.0.0.1:1/"
def test_url_vacia_devuelve_error():
res = fetch_http_fingerprint_cdp(" ")
assert res["status"] == "error"
assert "url vacia" in res["error"]
def test_happy_path_monkeypatch(monkeypatch):
# Fake del pipeline: devuelve un tab_id sin tocar red.
def fake_open(debug_port, url, timeout_s=30):
assert url == "https://some-spa.com/"
return "TAB123"
# Fake del eval: primera llamada (recoger senales) devuelve el JSON de la SPA;
# llamadas posteriores (window.close) devuelven ok vacio.
calls = {"n": 0}
spa_html = '<html><body><div id="__next">hi</div></body></html>'
def fake_eval(expression, *, port=9222, target_url_substr="", await_promise=False, timeout_s=10.0):
calls["n"] += 1
if "outerHTML" in expression:
import json as _json
payload = _json.dumps({
"html": spa_html,
"title": "Some SPA",
"href": "https://some-spa.com/home",
"cookie": "session=SECRETVALUE; theme=dark",
})
return {"ok": True, "value": payload, "error": "", "target_url": "https://some-spa.com/"}
return {"ok": True, "value": None, "error": "", "target_url": "https://some-spa.com/"}
monkeypatch.setattr(mod, "cdp_open_url_and_wait", fake_open)
monkeypatch.setattr(mod, "cdp_eval", fake_eval)
res = fetch_http_fingerprint_cdp("https://some-spa.com/", port=9222, wait_render_s=0)
assert res["status"] == "ok"
assert res["rendered"] is True
assert '<div id="__next">' in res["html"]
assert res["html_len"] == len(spa_html)
assert res["title"] == "Some SPA"
assert res["final_url"] == "https://some-spa.com/home"
# Cookies: SOLO nombres, jamas valores.
assert res["cookies"] == ["session", "theme"]
assert "SECRETVALUE" not in str(res["cookies"])
# Compatibilidad con detect_web_tech: status_code None, headers vacio.
assert res["status_code"] is None
assert res["headers"] == {}
def test_happy_path_eval_falla_devuelve_error(monkeypatch):
def fake_open(debug_port, url, timeout_s=30):
return "TAB123"
def fake_eval(expression, *, port=9222, target_url_substr="", await_promise=False, timeout_s=10.0):
return {"ok": False, "value": None, "error": "boom", "target_url": ""}
monkeypatch.setattr(mod, "cdp_open_url_and_wait", fake_open)
monkeypatch.setattr(mod, "cdp_eval", fake_eval)
res = fetch_http_fingerprint_cdp("https://x.com/", wait_render_s=0)
assert res["status"] == "error"
assert "boom" in res["error"]