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:
2026-06-14 15:12:07 +02:00
parent d89da1292d
commit 935008ec3f
49 changed files with 6659 additions and 302 deletions
@@ -0,0 +1,57 @@
"""Tests para grab_service_banner (sin red externa; servidor TCP local fake)."""
import os
import socket
import socketserver
import sys
import threading
sys.path.insert(0, os.path.dirname(__file__))
from grab_service_banner import grab_service_banner
class _BannerHandler(socketserver.BaseRequestHandler):
"""Emite un banner SSH fake al conectar, como hace un servidor SSH real."""
def handle(self):
try:
self.request.sendall(b"SSH-2.0-TestServer\r\n")
except OSError:
pass
def test_identifica_ssh_de_banner_local():
"""Un servidor TCP local que emite 'SSH-2.0-...' se identifica como ssh."""
server = socketserver.TCPServer(("127.0.0.1", 0), _BannerHandler)
# bind_and_activate por defecto ya hizo bind; tomamos el puerto efimero.
host, port = server.server_address
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
result = grab_service_banner(host, port, timeout_s=2.0, send_probe=False)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2.0)
assert result["status"] == "ok"
assert result["service"] == "ssh"
assert "SSH-2.0-TestServer" in result["banner"]
assert result["product"] == "TestServer" or result["product"] # best-effort
def test_host_vacio_devuelve_error():
"""Un host vacio devuelve status error sin lanzar y sin tocar la red."""
result = grab_service_banner("", 22)
assert result["status"] == "error"
assert "vacio" in result["error"]
assert set(["status", "error", "host", "port"]).issubset(result.keys())
def test_port_fuera_de_rango_devuelve_error():
"""Un puerto fuera del rango 1..65535 devuelve status error sin conectar."""
result = grab_service_banner("127.0.0.1", 70000)
assert result["status"] == "error"
assert "rango" in result["error"]
assert result["port"] == 70000