"""Pipeline scan_port_services. One-shot que materializa el flujo "escanear los servicios de los diferentes puertos de un host": escanea puertos TCP y, para cada puerto ABIERTO, obtiene (a) el servicio que se espera por convencion IANA en ese puerto y (b) el servicio/version REAL leyendo el banner del servicio en vivo. Opcionalmente archiva la evidencia en OSINT. Convierte el patron de N llamadas (1 scan + 2*K por cada puerto abierto: identify_port_service + grab_service_banner) en una sola invocacion. Compone funciones del registry del dominio cybersecurity; no reescribe ninguna logica de escaneo, identificacion ni persistencia. Funciones del registry compuestas (importadas, no reimplementadas): scan_tcp_ports, identify_port_service, grab_service_banner, save_scan_to_osint """ from cybersecurity import ( scan_tcp_ports, identify_port_service, grab_service_banner, save_scan_to_osint, ) def _build_raw(host: str, ip: str, services: list[dict]) -> str: """Construye una tabla legible PORT / EXPECTED / ACTUAL / BANNER para evidencia. Args: host: host objetivo del escaneo. ip: IP resuelta del host. services: lista de dicts de servicio (ver scan_port_services). Returns: Bloque de texto multi-linea con cabecera y una fila por puerto abierto. """ header = f"# scan_port_services {host} ({ip})" cols = f"{'PORT':<8}{'EXPECTED':<16}{'ACTUAL':<16}BANNER" lines = [header, "", cols] for s in services: port = str(s.get("port", "")) expected = str(s.get("expected_service") or "") actual = str(s.get("actual_service") or "") banner = (s.get("banner") or "").replace("\r", " ").replace("\n", " ").strip() if len(banner) > 80: banner = banner[:77] + "..." lines.append(f"{port:<8}{expected:<16}{actual:<16}{banner}") return "\n".join(lines) def scan_port_services( host: str, ports: "str | list[int]" = "common", timeout_s: float = 1.0, workers: int = 100, grab_banners: bool = True, banner_timeout_s: float = 3.0, save: bool = True, ) -> dict: """Escanea puertos de un host e identifica el servicio (esperado y real) de cada uno. Compone, en un solo paso: 1. ``scan_tcp_ports(host, ports, ...)`` para hallar los puertos abiertos. 2. Por cada puerto abierto, ``identify_port_service(port)`` (servicio que la convencion IANA espera ahi) y, si ``grab_banners`` es True, ``grab_service_banner(host, port, ...)`` (servicio/version REAL leido del banner en vivo). 3. Si ``save`` es True, archiva una tabla de evidencia en OSINT via ``save_scan_to_osint`` con ``scan_type="port_services"``. Nunca lanza excepciones: cualquier fallo se refleja en la clave ``status`` del dict devuelto. Args: host: hostname o IP objetivo (ej. "127.0.0.1", "scanme.nmap.org"). ports: especificacion de puertos, se pasa tal cual a scan_tcp_ports. Acepta lista de ints ([22, 80, 443]), string preset "common" (~30 puertos comunes), string rango "1-1024" o string CSV "22,80,443" (con rangos mezclados). timeout_s: timeout por conexion TCP del escaneo de puertos, en segundos. Default 1.0. workers: numero de hilos concurrentes del escaneo. Default 100. grab_banners: si True (default) llama grab_service_banner por cada puerto abierto para identificar el servicio/version real; si False solo usa identify_port_service (sin tocar el servicio en vivo -> mas rapido y mas sigiloso, sin segunda ronda de conexiones). banner_timeout_s: timeout del grab de banner por puerto, en segundos. Default 3.0. Solo aplica si grab_banners=True. save: si True (default) archiva la evidencia en OSINT via save_scan_to_osint (scan_type="port_services"); si False solo ejecuta el escaneo y no toca el vault ni el service. Si el sink falla, el resultado degrada sin romper (osint.status="error"). Returns: dict de estado. Nunca lanza. ok:: { "status": "ok", "host": , "ip": , "open_ports": [22, 5432, 6379], "services": [ { "port": 22, "expected_service": "ssh", "expected_desc": "Secure Shell", "actual_service": "ssh", # None si grab_banners=False "product": "OpenSSH", # "" si no se grabo banner "version": "9.6p1", # "" si no se grabo banner "banner": "SSH-2.0-OpenSSH_9.6p1 ...", # "" si no se grabo "match": True, # expected==actual; None si no se grabo }, ... ], "saved": | None, "raw": "# scan_port_services ...\nPORT EXPECTED ...", } error (el escaneo de puertos fallo: host no resuelve, spec invalida):: {"status": "error", "stage": "scan", "scan": } """ # 1. Escaneo de puertos. Si falla, propagamos sin continuar. scan = scan_tcp_ports(host, ports=ports, timeout_s=timeout_s, workers=workers) if scan.get("status") != "ok": return {"status": "error", "stage": "scan", "scan": scan} ip = scan.get("ip", "") open_ports = list(scan.get("open") or []) # 2. Por cada puerto abierto: servicio esperado (puro) + servicio real (banner). # Secuencial a proposito (KISS): los puertos abiertos suelen ser pocos y # cada grab ya tiene su propio timeout acotado. services: list[dict] = [] for port in open_ports: expected = identify_port_service(port, proto="tcp") entry = { "port": port, "expected_service": expected.get("service"), "expected_desc": expected.get("description"), "actual_service": None, "product": "", "version": "", "banner": "", "match": None, } if grab_banners: banner_res = grab_service_banner( host, port, timeout_s=banner_timeout_s, send_probe=True ) if banner_res.get("status") == "ok": actual = banner_res.get("service") entry["actual_service"] = actual entry["product"] = banner_res.get("product", "") entry["version"] = banner_res.get("version", "") entry["banner"] = banner_res.get("banner", "") # match solo es significativo si ambos servicios son concretos. exp = expected.get("service") entry["match"] = ( exp == actual if actual and actual != "unknown" and exp and exp != "unknown" else False ) else: # El grab fallo (timeout/refused): dejamos actual como "unknown" # para distinguirlo de "no se intento" (None con grab_banners=False). entry["actual_service"] = "unknown" entry["match"] = False services.append(entry) raw = _build_raw(host, ip, services) # 3. Archiva la evidencia en OSINT si procede (degrada sin romper). saved = None if save: summary = { "open_ports": open_ports, "services_detected": [ f"{s['port']}:{s.get('actual_service') or s.get('expected_service')}" for s in services ], } saved = save_scan_to_osint( host, "port_services", raw, summary=summary, tool="scan_port_services", ) return { "status": "ok", "host": host, "ip": ip, "open_ports": open_ports, "services": services, "saved": saved, "raw": raw, } def _parse_cli(argv: list[str]) -> dict: """Parsea los args de CLI: [ports] [--no-banners] [--no-save]. Devuelve un dict de kwargs para scan_port_services. """ positional: list[str] = [] grab_banners = True save = True for arg in argv: if arg == "--no-banners": grab_banners = False elif arg == "--no-save": save = False else: positional.append(arg) return {"positional": positional, "grab_banners": grab_banners, "save": save} if __name__ == "__main__": import sys parsed = _parse_cli(sys.argv[1:]) positional = parsed["positional"] host = positional[0] if len(positional) >= 1 else "127.0.0.1" ports = positional[1] if len(positional) >= 2 else "common" try: result = scan_port_services( host, ports=ports, grab_banners=parsed["grab_banners"], save=parsed["save"], ) print("status:", result.get("status")) if result.get("status") == "ok": print(f"host: {result['host']} ({result['ip']})") print("open_ports:", result["open_ports"]) print("--- services ---") print(result["raw"]) saved = result.get("saved") or {} if saved: print("note_path:", saved.get("note_path")) print("registered:", saved.get("registered")) else: print("error:", result.get("scan", {}).get("error")) except Exception as exc: # noqa: BLE001 - smoke nunca debe romper print("smoke exception (tolerada):", repr(exc))