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>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"""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"])
|
||||
Reference in New Issue
Block a user