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:
2026-06-14 15:12:07 +02:00
parent d89da1292d
commit 935008ec3f
49 changed files with 6659 additions and 302 deletions
@@ -0,0 +1,240 @@
"""Connect-scan TCP concurrente de un host usando SOLO stdlib (socket + threads).
Funcion IMPURA: abre conexiones TCP (connect) contra una lista o rango de
puertos de un host, en paralelo con un ThreadPoolExecutor. NO requiere nmap ni
sudo: es un connect-scan simple (full three-way handshake), por lo que no es
sigiloso pero funciona desde cualquier entorno Python sin privilegios.
Complementa a `nmap_scan` cuando no se quiere/puede usar nmap o se busca un
escaneo rapido en Python puro. A diferencia de nmap_scan, NO detecta version de
servicio: solo reporta el estado del puerto (open/closed/filtered).
NO lanza excepciones: devuelve un dict con `status` "ok" o "error" y un campo
`raw` legible pensado para guardar como evidencia OSINT. Solo escanear hosts
autorizados/propios.
"""
import socket
from concurrent.futures import ThreadPoolExecutor, as_completed
# ~30 puertos TCP comunes para el preset "common".
_COMMON_PORTS = [
21, 22, 23, 25, 53, 80, 110, 111, 135, 139, 143, 443, 445, 993, 995,
1723, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9200, 11211, 27017,
1433, 2049, 5060, 8000,
]
def _parse_ports(ports) -> list[int]:
"""Normaliza la especificacion de puertos a una lista ordenada de ints unicos.
Acepta cuatro formas:
- lista de ints: ``[22, 80, 443]``
- string preset: ``"common"`` (~30 puertos comunes)
- string rango: ``"1-1024"``
- string CSV: ``"22,80,443"`` (admite tambien rangos mezclados:
``"22,80,8000-8010"``)
Args:
ports: lista de ints o string en una de las formas anteriores.
Returns:
Lista ordenada de ints unicos en el rango valido 1..65535.
Raises:
ValueError: si el formato es invalido o no quedan puertos validos.
(Uso interno; `scan_tcp_ports` lo captura y devuelve status error.)
"""
if isinstance(ports, (list, tuple, set)):
out = set()
for p in ports:
pi = int(p)
if 1 <= pi <= 65535:
out.add(pi)
if not out:
raise ValueError("lista de puertos vacia o sin puertos validos (1..65535)")
return sorted(out)
if not isinstance(ports, str):
raise ValueError(f"ports debe ser str o lista de ints, no {type(ports).__name__}")
spec = ports.strip().lower()
if not spec:
raise ValueError("spec de puertos vacia")
if spec == "common":
return sorted(set(_COMMON_PORTS))
out = set()
for chunk in spec.split(","):
chunk = chunk.strip()
if not chunk:
continue
if "-" in chunk:
lo_s, hi_s = chunk.split("-", 1)
lo, hi = int(lo_s), int(hi_s)
if lo > hi:
lo, hi = hi, lo
for pi in range(lo, hi + 1):
if 1 <= pi <= 65535:
out.add(pi)
else:
pi = int(chunk)
if 1 <= pi <= 65535:
out.add(pi)
if not out:
raise ValueError(f"no se obtuvieron puertos validos de '{ports}'")
return sorted(out)
def _probe_port(ip: str, port: int, timeout_s: float) -> str:
"""Sondea un puerto TCP via connect y clasifica su estado.
Returns:
"open" -> connect_ex == 0 (handshake completo).
"closed" -> RST / ConnectionRefused.
"filtered" -> timeout o host inalcanzable (probable firewall).
"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout_s)
rc = sock.connect_ex((ip, port))
if rc == 0:
return "open"
# ECONNREFUSED (111 Linux / 10061 Win) -> puerto cerrado pero host vivo.
if rc in (111, 10061):
return "closed"
return "filtered"
except (socket.timeout, TimeoutError):
return "filtered"
except (ConnectionRefusedError, OSError):
return "closed"
def scan_tcp_ports(
host: str,
ports="common",
timeout_s: float = 1.0,
workers: int = 100,
) -> dict:
"""Escanea puertos TCP de un host por connect-scan concurrente (stdlib).
Resuelve `host` a IP, parsea la spec de puertos y lanza un connect TCP por
cada puerto en paralelo (ThreadPoolExecutor). Clasifica cada puerto como
open / closed / filtered y agrega los resultados.
Args:
host: Hostname o IP objetivo (ej. "scanme.nmap.org", "127.0.0.1"). Se
resuelve con socket.gethostbyname; si no resuelve, status error.
ports: Especificacion de puertos. Acepta lista de ints ([22, 80, 443]),
string preset "common" (~30 puertos comunes, default), string rango
"1-1024", o string CSV "22,80,443" (con rangos mezclados
"22,80,8000-8010"). Spec invalida devuelve status error.
timeout_s: Timeout por conexion TCP en segundos. Default 1.0. Bajo en
redes lentas puede marcar puertos abiertos como filtered.
workers: Numero de hilos concurrentes. Default 100. Se acota a >=1 y al
numero de puertos a escanear. Valores muy altos pueden saturar
descriptores de archivo o la red.
Returns:
Dict con status "ok" o "error". Nunca lanza.
ok::
{
"status": "ok",
"host": <host>,
"ip": <ip resuelta>,
"ports_scanned": <int>,
"open": [<int>, ...], # ordenada
"closed_count": <int>,
"filtered_count": <int>,
"results": [{"port": int, "state": str}, ...], # ordenado por puerto
"raw": <bloque PORT/STATE legible para evidencia OSINT>,
}
error (host no resuelve, spec invalida)::
{"status": "error", "error": <str>, "host": <host>}
"""
if not host or not host.strip():
return {"status": "error", "error": "scan_tcp_ports: host vacio", "host": host}
host = host.strip()
# Parsear puertos.
try:
port_list = _parse_ports(ports)
except (ValueError, TypeError) as exc:
return {
"status": "error",
"error": f"scan_tcp_ports: spec de puertos invalida: {exc}",
"host": host,
}
# Resolver host a IP.
try:
ip = socket.gethostbyname(host)
except socket.gaierror as exc:
return {
"status": "error",
"error": f"scan_tcp_ports: no se pudo resolver host '{host}': {exc}",
"host": host,
}
n_workers = max(1, min(int(workers), len(port_list)))
# Sondeo concurrente.
states: dict[int, str] = {}
with ThreadPoolExecutor(max_workers=n_workers) as pool:
futures = {
pool.submit(_probe_port, ip, port, timeout_s): port
for port in port_list
}
for fut in as_completed(futures):
port = futures[fut]
try:
states[port] = fut.result()
except Exception: # noqa: BLE001 - un probe nunca debe tumbar el scan
states[port] = "filtered"
results = [{"port": p, "state": states[p]} for p in sorted(states)]
open_ports = sorted(p for p, st in states.items() if st == "open")
closed_count = sum(1 for st in states.values() if st == "closed")
filtered_count = sum(1 for st in states.values() if st == "filtered")
# Bloque legible para evidencia (solo open/filtered; los closed se omiten
# para que el raw sea util sin ahogarlo en cientos de "closed").
raw_lines = ["PORT STATE"]
for r in results:
if r["state"] != "closed":
raw_lines.append(f"{r['port']:<5}/tcp {r['state']}")
raw = "\n".join(raw_lines)
return {
"status": "ok",
"host": host,
"ip": ip,
"ports_scanned": len(port_list),
"open": open_ports,
"closed_count": closed_count,
"filtered_count": filtered_count,
"results": results,
"raw": raw,
}
if __name__ == "__main__":
# Smoke: scan rapido contra el host oficial de pruebas de nmap (legal escanear).
# Tolera fallo de red sin romper.
try:
result = scan_tcp_ports("scanme.nmap.org", ports="common", timeout_s=2.0)
print(result["status"])
if result["status"] == "ok":
print(f"[ok] {result['host']} ({result['ip']}) "
f"escaneados={result['ports_scanned']} abiertos={result['open']}")
print("--- raw ---")
print(result["raw"])
else:
print("error:", result.get("error"))
except Exception as exc: # noqa: BLE001 - smoke nunca debe romper
print("smoke fallo (tolerado):", exc)