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>
255 lines
9.6 KiB
Python
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))
|