1430039688
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>
408 lines
15 KiB
Python
408 lines
15 KiB
Python
"""Pipeline fingerprint_web_stack.
|
|
|
|
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). 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 (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_cdp
|
|
"""
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
from cybersecurity import (
|
|
fetch_http_fingerprint,
|
|
detect_web_tech,
|
|
save_scan_to_osint,
|
|
)
|
|
from browser.fetch_http_fingerprint_cdp import fetch_http_fingerprint_cdp
|
|
|
|
|
|
def _build_raw(
|
|
url: str,
|
|
final_url: str,
|
|
status_code: int,
|
|
server: str,
|
|
title: str,
|
|
technologies: list[dict],
|
|
html_source: str = "static",
|
|
) -> str:
|
|
"""Construye una tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE para evidencia.
|
|
|
|
NO incluye el HTML entero ni valores de cookie: solo metadatos de respuesta y
|
|
la matriz de tecnologias detectadas.
|
|
|
|
Args:
|
|
url: URL solicitada.
|
|
final_url: URL final tras redirects.
|
|
status_code: codigo HTTP de la respuesta.
|
|
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}",
|
|
"",
|
|
f"url: {url}",
|
|
f"final_url: {final_url}",
|
|
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"
|
|
lines = header_lines + [cols]
|
|
for t in technologies:
|
|
name = str(t.get("name", ""))
|
|
category = str(t.get("category", ""))
|
|
version = str(t.get("version") or "")
|
|
confidence = str(t.get("confidence", ""))
|
|
lines.append(f"{name:<24}{category:<22}{version:<14}{confidence}")
|
|
if not technologies:
|
|
lines.append("(no technologies detected)")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _target_from_url(url: str, final_url: str) -> str:
|
|
"""Deriva el target (host) para el archivado OSINT a partir de la URL.
|
|
|
|
Prefiere el host de la URL solicitada; si no se puede parsear, cae al host de
|
|
la URL final tras redirects; si tampoco, devuelve la cadena cruda.
|
|
|
|
Args:
|
|
url: URL solicitada.
|
|
final_url: URL final tras redirects.
|
|
|
|
Returns:
|
|
El host (sin esquema ni path), o la URL cruda si no se pudo extraer.
|
|
"""
|
|
for candidate in (url, final_url):
|
|
if not candidate:
|
|
continue
|
|
try:
|
|
host = urlparse(candidate).hostname
|
|
except ValueError:
|
|
host = None
|
|
if host:
|
|
return host
|
|
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 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).
|
|
|
|
Nunca lanza excepciones: cualquier fallo se refleja en la clave ``status``
|
|
del dict devuelto.
|
|
|
|
Args:
|
|
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 (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.
|
|
max_html_bytes: corta el HTML leido a este tamano para no descargar megas.
|
|
Default 500_000 (500 KB). Se pasa a fetch_http_fingerprint.
|
|
save: si True (default) archiva la evidencia en OSINT via
|
|
save_scan_to_osint con scan_type="web_tech"; 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").
|
|
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.
|
|
ok::
|
|
|
|
{
|
|
"status": "ok",
|
|
"url": <url solicitada>,
|
|
"final_url": <url tras redirects>,
|
|
"status_code": int,
|
|
"server": str, # cabecera Server, "" si no hay
|
|
"title": str, # titulo de la pagina, "" si no hay
|
|
"technologies": [ # tal cual de detect_web_tech
|
|
{"name", "category", "version", "confidence", "evidence"},
|
|
...
|
|
],
|
|
"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 estatico fallo Y use_cdp es False, o ambos fallaron:
|
|
host no resuelve, conexion rechazada, timeout)::
|
|
|
|
{"status": "error", "stage": "fetch", "url": <url>, "fetch": <dict>}
|
|
"""
|
|
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,
|
|
)
|
|
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",
|
|
"url": url,
|
|
"fetch": fp,
|
|
}
|
|
|
|
# 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. 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(
|
|
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, html_source
|
|
)
|
|
|
|
# 4. Archiva la evidencia en OSINT si procede (degrada sin romper).
|
|
saved = None
|
|
if save:
|
|
target = _target_from_url(url, final_url)
|
|
summary = {
|
|
"count": count,
|
|
"by_category": by_category,
|
|
"server": server,
|
|
"status_code": status_code,
|
|
"html_source": html_source,
|
|
}
|
|
saved = save_scan_to_osint(
|
|
target,
|
|
"web_tech",
|
|
raw,
|
|
summary=summary,
|
|
tool="fingerprint_web_stack",
|
|
)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"url": url,
|
|
"final_url": final_url,
|
|
"status_code": status_code,
|
|
"server": server,
|
|
"title": title,
|
|
"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] [--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
|
|
|
|
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,
|
|
"use_cdp": use_cdp,
|
|
"cdp_port": cdp_port,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
parsed = _parse_cli(sys.argv[1:])
|
|
positional = parsed["positional"]
|
|
target_url = positional[0] if len(positional) >= 1 else "https://example.com"
|
|
|
|
try:
|
|
result = fingerprint_web_stack(
|
|
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 {}
|
|
if saved:
|
|
print("note_path:", saved.get("note_path"))
|
|
print("registered:", saved.get("registered"))
|
|
else:
|
|
print("error:", result.get("fetch", {}).get("error"))
|
|
except Exception as exc: # noqa: BLE001 - smoke nunca debe romper
|
|
print("smoke exception (tolerada):", repr(exc))
|