935008ec3f
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>
241 lines
8.5 KiB
Python
241 lines
8.5 KiB
Python
"""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)
|