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"]
@@ -3,15 +3,16 @@ name: fingerprint_web_stack
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
version: "1.1.0"
purity: impure
signature: "def fingerprint_web_stack(url: str, timeout_s: float = 15.0, verify_tls: bool = True, max_html_bytes: int = 500_000, save: bool = True) -> dict"
description: "One-shot que detecta la tecnologia web (stack tecnologico estilo Wappalyzer) de una URL: hace el fetch HTTP de las senales (fetch_http_fingerprint) y matchea las firmas (detect_web_tech), devolviendo las tecnologias detectadas — servidor, lenguaje, CMS, framework web, frameworks JS, librerias, analytics, CDN, e-commerce, WAF — con categoria, version y confidence. Reemplaza el patron fetch_http_fingerprint -> detect_web_tech por una sola llamada. El equivalente registry de Wappalyzer / whatweb / un fingerprint de stack de una url. Opcionalmente archiva la evidencia (tabla TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE) en OSINT. Util para reconocimiento web, auditoria de superficie y averiguar que CMS framework servidor usa un sitio."
tags: [recon, web-recon, pipelines, cybersecurity, fingerprint, wappalyzer, web-tech, sink]
signature: "def fingerprint_web_stack(url: str, timeout_s: float = 15.0, verify_tls: bool = True, max_html_bytes: int = 500_000, save: bool = True, use_cdp: bool = False, cdp_port: int = 9222, wait_render_s: float = 2.0) -> dict"
description: "One-shot que detecta la tecnologia web (stack tecnologico estilo Wappalyzer) de una URL: hace el fetch HTTP de las senales (fetch_http_fingerprint) y matchea las firmas (detect_web_tech), devolviendo las tecnologias detectadas — servidor, lenguaje, CMS, framework web, frameworks JS, librerias, analytics, CDN, e-commerce, WAF — con categoria, version y confidence. Reemplaza el patron fetch_http_fingerprint -> detect_web_tech por una sola llamada. El equivalente registry de Wappalyzer / whatweb / un fingerprint de stack de una url. Con use_cdp=True ademas analiza el HTML RENDERIZADO tras ejecutar JavaScript (fetch_http_fingerprint_cdp via Chrome remoto) para detectar SPAs (React/Vue/Angular/Next) que el fetch estatico no ve; si no hay Chrome degrada a estatico con un warning. Opcionalmente archiva la evidencia (tabla TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE) en OSINT. Util para reconocimiento web, auditoria de superficie y averiguar que CMS framework servidor usa un sitio, incluidas single-page applications."
tags: [recon, web-recon, pipelines, cybersecurity, fingerprint, wappalyzer, web-tech, sink, cdp, spa, render]
uses_functions:
- fetch_http_fingerprint_py_cybersecurity
- detect_web_tech_py_cybersecurity
- save_scan_to_osint_py_cybersecurity
- fetch_http_fingerprint_cdp_py_browser
uses_types: []
returns: []
returns_optional: false
@@ -28,9 +29,15 @@ params:
desc: "Corta el HTML leido a este tamano para no descargar megas. Default 500_000 (500 KB). Se pasa a fetch_http_fingerprint."
- name: save
desc: "Si True (default) archiva la evidencia en OSINT via save_scan_to_osint con scan_type='web_tech' (target = host de la URL); si False solo ejecuta el fetch + matching y no toca el vault ni el service osint_db. Politica recon: todo scan se archiva. Si el sink falla, el resultado degrada sin romper (saved.status='error')."
output: "dict con status ('ok'|'error'), url, final_url (tras redirects), status_code (int), server (cabecera Server o ''), title (titulo de la pagina o ''), technologies (lista de dicts con name, category, version, confidence, evidence — tal cual de detect_web_tech), by_category (dict categoria -> lista de nombres), count (int), saved (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False) y raw (tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE con cabecera de url/status/server/title). Si el fetch HTTP falla (host no resuelve, conexion rechazada, timeout) -> {status:error, stage:fetch, url:..., fetch:<dict>}. Nunca lanza."
- name: use_cdp
desc: "Si True, ademas del fetch estatico hace un fetch via Chrome DevTools Protocol (fetch_http_fingerprint_cdp) para analizar el HTML RENDERIZADO tras ejecutar JavaScript y detectar SPAs (React/Vue/Angular/Next) que el HTML inicial vacio no revela. Requiere un Chrome con remote debugging en cdp_port. Si el CDP no esta disponible, DEGRADA al HTML estatico con un warning (no falla). Default False (comportamiento estatico clasico, sin regresion)."
- name: cdp_port
desc: "Puerto de remote debugging del Chrome a usar cuando use_cdp=True. Default 9222 (navegador diario, activado global — mezcla tu sesion personal). Para recon de terceros sin contaminar tu sesion, usar 9333 (Chrome aislado del browser_mcp)."
- name: wait_render_s
desc: "Segundos de espera tras el load event para que la SPA pinte el DOM (solo aplica con use_cdp=True). Default 2.0. Subir (4.0-6.0) para SPAs lentas con mucho data-fetching; un valor corto puede dejar el HTML incompleto."
output: "dict con status ('ok'|'error'), url, final_url (tras redirects), status_code (int), server (cabecera Server o ''), title (titulo de la pagina o ''), technologies (lista de dicts con name, category, version, confidence, evidence — tal cual de detect_web_tech), by_category (dict categoria -> lista de nombres), count (int), html_source ('static'|'cdp' — fuente del HTML analizado), rendered (bool, True si html_source=='cdp'), warnings (lista de avisos, p.ej. degradacion CDP->estatico; vacia si no hubo), saved (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False) y raw (tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE con cabecera de url/status/server/title/html_source). Si el fetch HTTP estatico falla y use_cdp=False (o ambos fallan) -> {status:error, stage:fetch, url:..., fetch:<dict>}. Nunca lanza."
tested: true
tests: ["test_golden_fingerprint_servidor_local_wordpress_nginx", "test_save_false_no_archiva_osint", "test_fetch_fallido_propaga_error_sin_red"]
tests: ["test_golden_fingerprint_servidor_local_wordpress_nginx", "test_save_false_no_archiva_osint", "test_fetch_fallido_propaga_error_sin_red", "test_use_cdp_sin_chrome_degrada_a_estatico"]
test_file_path: "python/functions/pipelines/fingerprint_web_stack_test.py"
file_path: "python/functions/pipelines/fingerprint_web_stack.py"
---
@@ -56,6 +63,20 @@ print(r["by_category"]) # {"cms": ["WordPress"], "web-server": ["nginx"], ...}
```python
from pipelines.fingerprint_web_stack import fingerprint_web_stack
# Modo CDP: analiza el HTML RENDERIZADO tras el JS (detecta SPAs React/Vue/Angular).
# Requiere Chrome con remote debugging en cdp_port (9222 diario / 9333 aislado).
r = fingerprint_web_stack("https://react.dev/", use_cdp=True, cdp_port=9222, save=False)
print(r["status"]) # "ok"
print(r["html_source"]) # "cdp" si habia Chrome; "static" (con warning) si no
print(r["rendered"]) # True si se uso el HTML renderizado
print(r["warnings"]) # [] si CDP ok; ["cdp no disponible: ...; usando fetch estatico"] si degrado
for t in r["technologies"]:
print(t["name"], t["category"]) # React javascript-framework, etc.
```
```python
from pipelines.fingerprint_web_stack import fingerprint_web_stack
# Con archivado en OSINT (default): deja una nota en el vault + POST al osint_db.
r = fingerprint_web_stack("https://midominio.example")
print(r["saved"]["note_path"]) # dominios/midominio.example/recon/web_tech-....md
@@ -66,6 +87,8 @@ print(r["saved"]["note_path"]) # dominios/midominio.example/recon/web_tech-....
./fn run fingerprint_web_stack https://example.com
# Flags: --no-save (no archiva OSINT), --no-verify-tls (cert self-signed, inseguro).
./fn run fingerprint_web_stack https://example.com --no-save
# Modo CDP (HTML renderizado tras JS): --cdp [--cdp-port 9333].
./fn run fingerprint_web_stack https://react.dev/ --cdp --no-save
```
## Cuando usarla
@@ -80,11 +103,26 @@ enriquecer una investigacion OSINT con el stack de un host.
## Gotchas
- **Fetch estatico: NO ejecuta JavaScript.** Solo ve el HTML inicial que devuelve
el servidor. Las SPAs que montan el framework (React/Vue/Angular/Svelte) en
runtime suelen servir un HTML casi vacio, asi que esos frameworks pueden NO
detectarse. Para sitios JS-pesados, un fingerprint con navegador real (CDP)
veria mas; este pipeline es la version sin navegador.
- **Fetch estatico (use_cdp=False): NO ejecuta JavaScript.** Solo ve el HTML
inicial que devuelve el servidor. Las SPAs que montan el framework
(React/Vue/Angular/Svelte) en runtime suelen servir un HTML casi vacio, asi que
esos frameworks pueden NO detectarse. Para sitios JS-pesados usa `use_cdp=True`
(analiza el HTML renderizado tras el JS via Chrome remoto).
- **`use_cdp=True` requiere Chrome con remote debugging** escuchando en `cdp_port`:
9222 (navegador diario, activado global) o 9333 (Chrome aislado del browser_mcp).
Si no hay Chrome, el pipeline NO falla: DEGRADA al HTML estatico, marca
`html_source="static"` y rellena `warnings` con `"cdp no disponible: ...; usando
fetch estatico"`. Comprueba siempre `result["warnings"]` para saber si el CDP se
aplico o si caiste al estatico.
- **Con `cdp_port=9222` se abre un tab en tu navegador PERSONAL** (mezcla cookies e
historial de tu sesion diaria). Para fingerprint de TERCEROS sin contaminar ni
filtrar tu sesion, usa `cdp_port=9333` (el Chrome aislado del browser_mcp).
- **`wait_render_s` puede ser corto para SPAs lentas**: el load event NO garantiza
el DOM pintado. Si el `html` renderizado sale incompleto (faltan frameworks que
deberian aparecer), sube `wait_render_s` a 4.0-6.0.
- **CDP no expone headers ni status_code**: con `use_cdp=True`, `server`,
`status_code` y `headers` siguen viniendo del fetch estatico (que siempre se
ejecuta); el CDP solo aporta el `html` renderizado y los nombres de cookie no-httponly.
- **La tabla de firmas es un subconjunto de Wappalyzer**, no exhaustiva. Un
tecnologia no listada en `detect_web_tech` no aparecera aunque este presente.
Para ampliar cobertura, anade entradas a `SIGNATURES` en `detect_web_tech`.
@@ -119,3 +157,7 @@ entero ni valores de cookie (las cookies de `fetch_http_fingerprint` ya son solo
nombres). El `target` para el archivado OSINT se deriva del host de la URL
(`urllib.parse.urlparse(...).hostname`). Nunca lanza excepciones: todo fallo se
refleja en la clave `status` del dict devuelto.
## Capability growth log
- v1.1.0 (2026-06-14) — anade modo use_cdp: usa fetch_http_fingerprint_cdp para analizar el HTML renderizado tras JS y detectar SPAs (React/Vue/Angular) que el fetch estatico no ve; degrada a estatico si no hay Chrome.
@@ -4,15 +4,18 @@ One-shot que materializa el flujo "averiguar la tecnologia web (stack) de una
URL" estilo Wappalyzer: hace el fetch HTTP de las senales (cabeceras, HTML,
cookies, titulo, servidor) y matchea las firmas para devolver las tecnologias
detectadas (servidor, lenguaje, CMS, frameworks JS, librerias, analytics, CDN,
e-commerce, WAF). Opcionalmente archiva la evidencia en OSINT.
e-commerce, WAF). Con use_cdp=True, ademas analiza el HTML RENDERIZADO tras
ejecutar JavaScript (via Chrome remoto) para detectar SPAs (React/Vue/Angular)
que el fetch estatico no ve. Opcionalmente archiva la evidencia en OSINT.
Convierte el patron de 2 llamadas (fetch_http_fingerprint -> detect_web_tech)
en una sola invocacion. Compone funciones del registry del dominio
cybersecurity; no reescribe ninguna logica de fetch, matching de firmas ni
persistencia.
cybersecurity (y browser para el modo CDP); no reescribe ninguna logica de
fetch, render, matching de firmas ni persistencia.
Funciones del registry compuestas (importadas, no reimplementadas):
fetch_http_fingerprint, detect_web_tech, save_scan_to_osint
fetch_http_fingerprint, detect_web_tech, save_scan_to_osint,
fetch_http_fingerprint_cdp
"""
from urllib.parse import urlparse
@@ -22,6 +25,7 @@ from cybersecurity import (
detect_web_tech,
save_scan_to_osint,
)
from browser.fetch_http_fingerprint_cdp import fetch_http_fingerprint_cdp
def _build_raw(
@@ -31,6 +35,7 @@ def _build_raw(
server: str,
title: str,
technologies: list[dict],
html_source: str = "static",
) -> str:
"""Construye una tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE para evidencia.
@@ -44,10 +49,13 @@ def _build_raw(
server: cadena del servidor (cabecera Server), puede ser "".
title: titulo de la pagina, puede ser "".
technologies: lista de dicts de tecnologia (ver fingerprint_web_stack).
html_source: fuente del HTML analizado ("static" = fetch estatico,
"cdp" = HTML renderizado post-JS via Chrome). Default "static".
Returns:
Bloque de texto multi-linea con cabecera y una fila por tecnologia.
"""
html_label = "cdp-rendered (post-JS)" if html_source == "cdp" else "static (sin JS)"
header_lines = [
f"# fingerprint_web_stack {url}",
"",
@@ -56,6 +64,7 @@ def _build_raw(
f"status_code: {status_code}",
f"server: {server or '-'}",
f"title: {title or '-'}",
f"html_source: {html_label}",
"",
]
cols = f"{'TECHNOLOGY':<24}{'CATEGORY':<22}{'VERSION':<14}CONFIDENCE"
@@ -96,22 +105,53 @@ def _target_from_url(url: str, final_url: str) -> str:
return (url or final_url or "unknown").strip()
def _union_cookie_names(static_cookies, cdp_cookies) -> list[str]:
"""Une los nombres de cookie de ambas fuentes (estatico + CDP), deduplicando.
Preserva el orden: primero los del fetch estatico (incluye httponly que CDP
no ve), luego los exclusivos del CDP. Solo nombres, nunca valores.
Args:
static_cookies: lista de nombres de cookie del fetch estatico.
cdp_cookies: lista de nombres de cookie del fetch CDP (document.cookie).
Returns:
Lista de nombres unicos en orden estable.
"""
out: list[str] = []
seen: set[str] = set()
for name in list(static_cookies or []) + list(cdp_cookies or []):
if name and name not in seen:
seen.add(name)
out.append(name)
return out
def fingerprint_web_stack(
url: str,
timeout_s: float = 15.0,
verify_tls: bool = True,
max_html_bytes: int = 500_000,
save: bool = True,
use_cdp: bool = False,
cdp_port: int = 9222,
wait_render_s: float = 2.0,
) -> dict:
"""Detecta la tecnologia web (stack) de una URL en un solo paso (estilo Wappalyzer).
Compone, en una sola invocacion:
1. ``fetch_http_fingerprint(url, ...)`` para recoger las senales crudas de
la respuesta (cabeceras, HTML, cookies, titulo, servidor).
2. ``detect_web_tech(headers, html, cookies, final_url)`` (PURA) para
matchear esas senales contra la tabla de firmas y obtener las
tecnologias detectadas.
3. Si ``save`` es True, archiva una tabla de evidencia en OSINT via
la respuesta (cabeceras, HTML inicial sin JS, cookies, titulo, servidor).
Aporta headers/server/status_code reales que CDP no expone.
2. Si ``use_cdp`` es True, ``fetch_http_fingerprint_cdp(url, ...)`` para
obtener el HTML RENDERIZADO tras ejecutar JavaScript (via Chrome remoto):
asi se detectan SPAs (React/Vue/Angular/Next) con HTML inicial vacio que
el fetch estatico pierde. Si el CDP falla (sin Chrome, etc.) DEGRADA al
HTML estatico sin romper y deja un warning.
3. ``detect_web_tech(headers, html, cookies, final_url)`` (PURA) para
matchear esas senales contra la tabla de firmas. El HTML analizado es el
del CDP cuando esta disponible, si no el del estatico.
4. Si ``save`` es True, archiva una tabla de evidencia en OSINT via
``save_scan_to_osint`` con ``scan_type="web_tech"`` (target = host de la
URL).
@@ -122,7 +162,7 @@ def fingerprint_web_stack(
url: URL objetivo. Sin esquema se asume https:// (fallback a http://),
tal como hace fetch_http_fingerprint.
timeout_s: timeout de la peticion HTTP en segundos. Default 15.0. Se pasa
tal cual a fetch_http_fingerprint.
tal cual a fetch_http_fingerprint (y al fetch CDP cuando use_cdp).
verify_tls: si False, no verifica el certificado TLS (inseguro, solo para
hosts propios con cert self-signed). Default True. Se pasa a
fetch_http_fingerprint.
@@ -133,6 +173,17 @@ def fingerprint_web_stack(
fetch + matching y no toca el vault ni el service osint_db. Politica
recon: todo scan se archiva. Si el sink falla, el resultado degrada
sin romper (saved.status="error").
use_cdp: si True, ademas del fetch estatico hace un fetch via Chrome
DevTools Protocol para analizar el HTML RENDERIZADO tras el JS y
detectar SPAs. Requiere un Chrome con remote debugging en cdp_port.
Si el CDP no esta disponible, DEGRADA al HTML estatico con un warning
(no falla). Default False (comportamiento estatico clasico, intacto).
cdp_port: puerto de remote debugging del Chrome a usar cuando use_cdp.
Default 9222 (navegador diario, global). Para recon de terceros sin
mezclar tu sesion personal, usar 9333 (Chrome aislado del browser_mcp).
wait_render_s: segundos de espera tras el load event para que la SPA
pinte el DOM (solo aplica con use_cdp). Default 2.0. Subir (4.0-6.0)
para SPAs lentas con mucho data-fetching.
Returns:
dict de estado. Nunca lanza.
@@ -151,23 +202,32 @@ def fingerprint_web_stack(
],
"by_category": {<categoria>: [<nombre>, ...], ...},
"count": int,
"html_source": "static" | "cdp", # fuente del HTML analizado
"rendered": bool, # True si html_source == "cdp"
"warnings": [<str>, ...], # vacia si no hubo degradacion
"saved": <dict de save_scan_to_osint> | None,
"raw": "# fingerprint_web_stack ...\nTECHNOLOGY ...",
}
error (el fetch HTTP fallo: host no resuelve, conexion rechazada,
timeout)::
error (el fetch HTTP estatico fallo Y use_cdp es False, o ambos fallaron:
host no resuelve, conexion rechazada, timeout)::
{"status": "error", "stage": "fetch", "url": <url>, "fetch": <dict>}
"""
# 1. Fetch de senales. Si el fetch falla del todo, propagamos sin continuar.
warnings: list[str] = []
# 1. Fetch estatico SIEMPRE: aporta headers/server/status_code reales (CDP no
# los da). Guardamos el resultado aunque falle: con use_cdp podemos seguir.
fp = fetch_http_fingerprint(
url,
timeout_s=timeout_s,
verify_tls=verify_tls,
max_html_bytes=max_html_bytes,
)
if fp.get("status") != "ok":
static_ok = fp.get("status") == "ok"
# Si el estatico falla del todo y NO vamos a intentar CDP, propagamos error.
if not static_ok and not use_cdp:
return {
"status": "error",
"stage": "fetch",
@@ -175,25 +235,68 @@ def fingerprint_web_stack(
"fetch": fp,
}
final_url = fp.get("final_url", "") or ""
status_code = fp.get("status_code", 0)
server = fp.get("server") or ""
title = fp.get("title") or ""
# Senales de respuesta: del estatico cuando hay (CDP no las expone).
headers = fp.get("headers") or {} if static_ok else {}
static_cookies = fp.get("cookies") or [] if static_ok else []
static_html = fp.get("html") or "" if static_ok else ""
final_url = (fp.get("final_url") or "") if static_ok else ""
status_code = fp.get("status_code", 0) if static_ok else 0
server = (fp.get("server") or "") if static_ok else ""
title = (fp.get("title") or "") if static_ok else ""
# 2. Matching de firmas (puro): no toca red, solo aplica regex deterministas.
# 2. Elegir el HTML a analizar y la fuente.
html_to_analyze = static_html
html_source = "static"
cookies = list(static_cookies)
if use_cdp:
cdp = fetch_http_fingerprint_cdp(
url,
port=cdp_port,
wait_render_s=wait_render_s,
timeout_s=timeout_s,
)
if cdp.get("status") == "ok":
# HTML renderizado post-JS: la clave para detectar SPAs.
html_to_analyze = cdp.get("html") or ""
html_source = "cdp"
cookies = _union_cookie_names(static_cookies, cdp.get("cookies") or [])
# El CDP ve la URL final tras redirects client-side y el titulo
# renderizado; preferimos los suyos cuando el estatico no aporta.
final_url = final_url or (cdp.get("final_url") or "")
if not title:
title = cdp.get("title") or ""
else:
# DEGRADA: sin Chrome (o fallo CDP) seguimos con el HTML estatico.
cdp_err = cdp.get("error") or "desconocido"
warnings.append(f"cdp no disponible: {cdp_err}; usando fetch estatico")
if not static_ok:
# Ni estatico ni CDP: ahora si es error (no hay HTML que analizar).
return {
"status": "error",
"stage": "fetch",
"url": url,
"fetch": fp,
"cdp": cdp,
"warnings": warnings,
}
# 3. Matching de firmas (puro): no toca red, solo aplica regex deterministas.
detection = detect_web_tech(
fp.get("headers") or {},
html=fp.get("html") or "",
cookies=fp.get("cookies") or [],
headers,
html=html_to_analyze,
cookies=cookies,
final_url=final_url,
)
technologies = detection.get("technologies", [])
by_category = detection.get("by_category", {})
count = detection.get("count", len(technologies))
raw = _build_raw(url, final_url, status_code, server, title, technologies)
raw = _build_raw(
url, final_url, status_code, server, title, technologies, html_source
)
# 3. Archiva la evidencia en OSINT si procede (degrada sin romper).
# 4. Archiva la evidencia en OSINT si procede (degrada sin romper).
saved = None
if save:
target = _target_from_url(url, final_url)
@@ -202,6 +305,7 @@ def fingerprint_web_stack(
"by_category": by_category,
"server": server,
"status_code": status_code,
"html_source": html_source,
}
saved = save_scan_to_osint(
target,
@@ -221,29 +325,52 @@ def fingerprint_web_stack(
"technologies": technologies,
"by_category": by_category,
"count": count,
"html_source": html_source,
"rendered": html_source == "cdp",
"warnings": warnings,
"saved": saved,
"raw": raw,
}
def _parse_cli(argv: list[str]) -> dict:
"""Parsea los args de CLI: <url> [--no-save] [--no-verify-tls].
"""Parsea los args de CLI: <url> [--no-save] [--no-verify-tls] [--cdp] [--cdp-port N].
Devuelve un dict de kwargs para fingerprint_web_stack.
"""
positional: list[str] = []
save = True
verify_tls = True
use_cdp = False
cdp_port = 9222
for arg in argv:
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--no-save":
save = False
elif arg == "--no-verify-tls":
verify_tls = False
elif arg == "--cdp":
use_cdp = True
elif arg == "--cdp-port":
i += 1
if i < len(argv):
try:
cdp_port = int(argv[i])
except ValueError:
pass
else:
positional.append(arg)
i += 1
return {"positional": positional, "save": save, "verify_tls": verify_tls}
return {
"positional": positional,
"save": save,
"verify_tls": verify_tls,
"use_cdp": use_cdp,
"cdp_port": cdp_port,
}
if __name__ == "__main__":
@@ -258,11 +385,16 @@ if __name__ == "__main__":
target_url,
verify_tls=parsed["verify_tls"],
save=parsed["save"],
use_cdp=parsed["use_cdp"],
cdp_port=parsed["cdp_port"],
)
print("status:", result.get("status"))
if result.get("status") == "ok":
print(f"url: {result['url']} -> {result['final_url']} ({result['status_code']})")
print("server:", result["server"] or "-")
print("html_source:", result.get("html_source"))
for w in result.get("warnings", []):
print("warning:", w)
print("--- technologies ---")
print(result["raw"])
saved = result.get("saved") or {}
@@ -178,3 +178,44 @@ def test_fetch_fallido_propaga_error_sin_red():
assert result["fetch"]["status"] == "error", result
# No se intento archivar nada.
assert save_called["n"] == 0, save_called
# --- 4. use_cdp sin Chrome: DEGRADA a estatico con warning (no falla) ---------
def test_use_cdp_sin_chrome_degrada_a_estatico():
"""use_cdp=True sin Chrome (cdp_port=1) degrada al fetch estatico con warning.
Levanta el mismo HTTPServer WordPress/nginx/PHP local que el golden y pide
use_cdp con cdp_port=1 (donde no hay ningun Chrome escuchando). El fetch CDP
falla, el pipeline NO rompe: usa el HTML estatico, marca html_source=static,
rellena warnings y sigue detectando WordPress/nginx por el html/headers.
"""
httpd, port, thread = _start_wp_server()
try:
result = fingerprint_web_stack(
f"http://127.0.0.1:{port}/",
timeout_s=5.0,
save=False,
use_cdp=True,
cdp_port=1, # puerto sin Chrome: el fetch CDP falla -> degrada
wait_render_s=0.0,
)
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2.0)
# Degrado, no fallo.
assert result["status"] == "ok", result
# Cayo al HTML estatico (CDP no disponible).
assert result["html_source"] == "static", result
assert result["rendered"] is False, result
# Hubo warning de degradacion.
assert result["warnings"], result
assert any("cdp no disponible" in w for w in result["warnings"]), result["warnings"]
# La deteccion estatica sigue funcionando.
names = {t["name"] for t in result["technologies"]}
assert "WordPress" in names, names
assert "nginx" in names, names
# No se archivo (save=False).
assert result["saved"] is None, result