"""GET HTTP(S) que recoge senales crudas para fingerprint de tecnologia web. Funcion IMPURA: hace una peticion HTTP(S) GET a una URL con User-Agent de navegador, sigue redirects y recoge TODAS las senales utiles para identificar el stack tecnologico del sitio (estilo Wappalyzer): cabeceras de respuesta normalizadas (lowercase), nombres de cookies, el HTML, el titulo y la cadena del servidor. Es la capa de RECOLECCION del fingerprinting web; el MATCHING de firmas vive en una funcion pura aparte (`detect_web_tech_py_cybersecurity`) que consume exactamente lo que esta devuelve. Devuelve siempre un dict (estilo del grupo recon): nunca lanza excepciones. Un 403/500 sigue siendo senal util de fingerprint, asi que un HTTPError se captura y se devuelve con su status_code real, headers y body. SEGURIDAD: en `cookies` solo se guardan los NOMBRES de las cookies, jamas los valores (un Set-Cookie lleva tokens de sesion sensibles). Solo usa stdlib (urllib, ssl, re, gzip, zlib). """ import gzip import re import socket import ssl import urllib.error import urllib.request import zlib _DEFAULT_UA = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" ) _TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) _CHARSET_RE = re.compile(r"charset=([\w-]+)", re.IGNORECASE) _COOKIE_NAME_RE = re.compile(r"^\s*([^=;\s]+)=") def _decompress(body: bytes, encoding: str) -> bytes: """Descomprime el body segun Content-Encoding (gzip/deflate). Best-effort.""" enc = (encoding or "").lower() try: if "gzip" in enc: return gzip.decompress(body) if "deflate" in enc: # deflate puede venir con o sin cabecera zlib. try: return zlib.decompress(body) except zlib.error: return zlib.decompress(body, -zlib.MAX_WBITS) except (OSError, zlib.error): # Si la descompresion falla, devuelve el body crudo (mejor algo que nada). return body return body def _decode_html(body: bytes, content_type: str) -> str: """Decodifica el HTML best-effort: charset del Content-Type -> utf-8 -> latin-1.""" charset = None m = _CHARSET_RE.search(content_type or "") if m: charset = m.group(1).strip() for enc in (charset, "utf-8", "latin-1"): if not enc: continue try: return body.decode(enc, errors="strict") except (LookupError, UnicodeDecodeError): continue # latin-1 nunca falla; ultimo recurso explicito. return body.decode("latin-1", errors="replace") def _extract_title(html: str) -> str | None: """Extrae el contenido de best-effort, colapsando espacios.""" m = _TITLE_RE.search(html) if not m: return None title = re.sub(r"\s+", " ", m.group(1)).strip() return title or None def _cookie_names(set_cookie_values: list[str]) -> list[str]: """Devuelve solo los NOMBRES de las cookies (nunca valores), deduplicados en orden.""" out: list[str] = [] seen: set[str] = set() for raw in set_cookie_values: m = _COOKIE_NAME_RE.match(raw or "") if not m: continue name = m.group(1) if name and name not in seen: seen.add(name) out.append(name) return out def _normalize_headers(headers) -> tuple[dict, list[str]]: """Normaliza headers a {clave_lower: valor_str} y extrae los Set-Cookie crudos. Si una cabecera se repite, gana el ultimo valor (salvo Set-Cookie, que se acumula aparte para extraer todos los nombres de cookie). Devuelve (headers_dict, lista_de_set_cookie_crudos). """ norm: dict[str, str] = {} set_cookies: list[str] = [] # http.client.HTTPMessage soporta .items() devolviendo cada par (con repetidos). for key, value in headers.items(): lk = key.lower() if lk == "set-cookie": set_cookies.append(value) continue norm[lk] = value return norm, set_cookies def _build_raw(status_line: str, headers: dict, cookie_names: list[str]) -> str: """Construye un bloque legible (status + headers + nombres de cookie) para evidencia. NO incluye el HTML entero (puede ser megas) ni valores de cookie (sensibles). """ lines = [status_line] for k in sorted(headers): lines.append(f"{k}: {headers[k]}") if cookie_names: lines.append("set-cookie-names: " + ", ".join(cookie_names)) return "\n".join(lines) def _do_get( url: str, timeout_s: float, verify_tls: bool, max_html_bytes: int, ua: str, ) -> dict: """Hace un GET unico a `url` y construye el dict de salida. Puede lanzar.""" req = urllib.request.Request( url, headers={ "User-Agent": ua, "Accept": ( "text/html,application/xhtml+xml,application/xml;q=0.9," "image/avif,image/webp,*/*;q=0.8" ), "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate", }, method="GET", ) context = None if verify_tls else ssl._create_unverified_context() try: resp = urllib.request.urlopen(req, timeout=timeout_s, context=context) status_code = resp.getcode() final_url = resp.geturl() resp_headers = resp.headers body = resp.read(max_html_bytes + 1) resp.close() except urllib.error.HTTPError as e: # Un error HTTP (403/404/500...) SIGUE siendo senal util de fingerprint: # tiene headers y a menudo body. Lo tratamos como respuesta valida. status_code = e.code final_url = e.geturl() or url resp_headers = e.headers body = e.read(max_html_bytes + 1) if e.fp is not None else b"" headers, set_cookie_raw = _normalize_headers(resp_headers) cookie_names = _cookie_names(set_cookie_raw) content_encoding = headers.get("content-encoding", "") body = _decompress(body, content_encoding) if len(body) > max_html_bytes: body = body[:max_html_bytes] html = _decode_html(body, headers.get("content-type", "")) title = _extract_title(html) server = headers.get("server") status_line = f"HTTP {status_code} {final_url}" raw = _build_raw(status_line, headers, cookie_names) return { "status": "ok", "url": url, "final_url": final_url, "status_code": status_code, "headers": headers, "cookies": cookie_names, "title": title, "server": server, "html": html, "html_len": len(html), "raw": raw, } def fetch_http_fingerprint( url: str, timeout_s: float = 15.0, verify_tls: bool = True, max_html_bytes: int = 500_000, user_agent: str | None = None, ) -> dict: """GET HTTP(S) que recoge senales crudas para fingerprint de tecnologia web. Funcion IMPURA: hace red. Manda un GET con User-Agent de navegador, sigue redirects (urllib los sigue por defecto) y recoge headers normalizados, nombres de cookies, HTML, titulo y servidor. Nunca lanza: cualquier fallo de red total devuelve ``{"status": "error", ...}``. Un error HTTP (403/500...) se devuelve como ``status: ok`` con su ``status_code`` real, porque sigue siendo senal de fingerprint. Si `url` no trae esquema, asume ``https://`` y, si la conexion HTTPS falla, reintenta con ``http://``. Args: url: URL objetivo. Sin esquema se asume https:// (fallback a http://). timeout_s: Timeout de la peticion en segundos. Default 15.0. verify_tls: Si False, crea un ssl context sin verificacion (inseguro, solo para recon de hosts propios con cert self-signed). Default True. max_html_bytes: Corta el HTML leido a este tamano para no descargar megas. Default 500_000 (500 KB). user_agent: User-Agent a enviar. Default un UA realista de Chrome. Returns: dict. En exito:: { "status": "ok", "url": <url solicitada>, "final_url": <url tras redirects>, "status_code": int, "headers": {clave_lower: valor_str, ...}, # ultimo valor si repetido "cookies": [<nombre_cookie>, ...], # SOLO nombres, nunca valores "title": str | None, "server": str | None, # atajo a headers["server"] "html": str, # cortado a max_html_bytes "html_len": int, "raw": str, # status + headers (sin html) } En error de red total (host no resuelve / conexion rechazada / timeout):: {"status": "error", "error": "<mensaje>", "url": <url>} SEGURIDAD: `cookies` lleva SOLO los nombres de las cookies de Set-Cookie, jamas los valores (que contienen tokens de sesion). """ if not url or not url.strip(): return {"status": "error", "error": "fetch_http_fingerprint: url vacia", "url": url} url = url.strip() ua = user_agent or _DEFAULT_UA # Construye la lista de URLs a intentar: si no hay esquema, https:// y luego # http:// como fallback. Si ya trae esquema, solo esa. if "://" in url: candidates = [url] else: candidates = ["https://" + url, "http://" + url] last_error: str | None = None for candidate in candidates: try: return _do_get(candidate, timeout_s, verify_tls, max_html_bytes, ua) except urllib.error.URLError as e: reason = getattr(e, "reason", e) last_error = f"{candidate}: {reason}" except socket.timeout: last_error = f"{candidate}: timeout tras {timeout_s}s" except ssl.SSLError as e: last_error = f"{candidate}: SSL error: {e}" except (OSError, ValueError) as e: # conexion rechazada, URL invalida, etc. last_error = f"{candidate}: {e}" return { "status": "error", "error": f"fetch_http_fingerprint: {last_error or 'fallo desconocido'}", "url": url, } if __name__ == "__main__": # Smoke test contra un sitio publico, best-effort (no rompe si no hay red). res = fetch_http_fingerprint("https://example.com") print("status:", res["status"]) if res["status"] == "ok": print(" final_url:", res["final_url"]) print(" status_code:", res["status_code"]) print(" server:", res["server"]) print(" title:", res["title"]) print(" cookies:", res["cookies"]) print(" html_len:", res["html_len"]) else: print(" (red no disponible, tolerado):", res["error"])