"""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": , "final_url": , "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": {: [, ...], ...}, "count": int, "html_source": "static" | "cdp", # fuente del HTML analizado "rendered": bool, # True si html_source == "cdp" "warnings": [, ...], # vacia si no hubo degradacion "saved": | 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": , "fetch": } """ 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: [--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))