Files
fn_registry/python/functions/pipelines/scan_port_services.py
T
egutierrez 935008ec3f 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>
2026-06-14 15:12:07 +02:00

255 lines
9.6 KiB
Python

"""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": <host>,
"ip": <ip resuelta>,
"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": <dict de save_scan_to_osint> | None,
"raw": "# scan_port_services ...\nPORT EXPECTED ...",
}
error (el escaneo de puertos fallo: host no resuelve, spec invalida)::
{"status": "error", "stage": "scan", "scan": <dict del 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: <host> [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))