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>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user