Files
fn_registry/python/functions/cybersecurity/fetch_http_fingerprint.py
T
egutierrez 935008ec3f feat(recon): grupo de reconocimiento de red + servicios + fingerprint web
Añade el capability group `recon` (dominio cybersecurity + pipelines, Python),
con la política de archivado OSINT y página madre docs/capabilities/recon.md.

Lookups y sondeo (wrappers de CLI):
- whois_lookup, rdap_lookup, dns_records, ping_host, traceroute_host, nmap_scan
- save_scan_to_osint (sink común) + recon_osint (pipeline one-shot scan+archivado)

Escaneo de puertos/servicios nativo (stdlib, sin nmap ni sudo):
- scan_tcp_ports: connect-scan TCP concurrente (open/closed/filtered)
- grab_service_banner: banner grab + identificación de servicio/versión real
- identify_port_service: puro, puerto -> servicio IANA esperado (~120 puertos)
- scan_port_services: pipeline one-shot (scan -> identify + banner por puerto abierto)

Fingerprint de tecnología web (estilo Wappalyzer), patrón pura/impura:
- fetch_http_fingerprint: GET stdlib, recoge headers/html/cookies (solo nombres)
- detect_web_tech: puro, matchea ~50 firmas regex -> tecnologías por categoría
- fingerprint_web_stack: pipeline one-shot url -> tecnologías

Todas devuelven dict {status} sin lanzar. Tests: 43 verdes, sin red externa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:12:07 +02:00

296 lines
11 KiB
Python

"""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"<title[^>]*>(.*?)</title>", 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 <title> 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"])