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,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)
|
||||
Reference in New Issue
Block a user