"""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": , "ip": , "ports_scanned": , "open": [, ...], # ordenada "closed_count": , "filtered_count": , "results": [{"port": int, "state": str}, ...], # ordenado por puerto "raw": , } error (host no resuelve, spec invalida):: {"status": "error", "error": , "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)