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,180 @@
"""Tests para el pipeline fingerprint_web_stack — SIN red externa ni service real.
El golden levanta un HTTPServer local efimero en 127.0.0.1 que emite cabeceras
(Server: nginx, X-Powered-By: PHP) + un HTML con `<meta name=generator>`
WordPress y marcadores `wp-content`. El pipeline compone fetch_http_fingerprint
+ detect_web_tech contra ese servidor real, asi se ejercita la composicion
end-to-end sin tocar internet. save=False en todos los tests para no escribir en
el vault OSINT ni hacer POST al service.
Para el error path, save_scan_to_osint se parchea sobre los globals del modulo
del pipeline (importlib + monkeypatch) por si acaso, pero con save=False nunca
debe invocarse.
"""
import http.server
import importlib
import os
import socketserver
import sys
import threading
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
# Globals del modulo del pipeline (donde viven fetch_http_fingerprint,
# detect_web_tech, save_scan_to_osint...).
mod = importlib.import_module("pipelines.fingerprint_web_stack")
fingerprint_web_stack = mod.fingerprint_web_stack
# HTML servido por el server local: marcadores claros de WordPress (meta
# generator + wp-content) para que detect_web_tech lo detecte high/medium.
_WP_HTML = (
b"<!DOCTYPE html>\n"
b"<html>\n<head>\n"
b"<meta charset=\"utf-8\">\n"
b"<meta name=\"generator\" content=\"WordPress 6.4.2\">\n"
b"<title>Mi Blog WordPress</title>\n"
b"<link rel=\"stylesheet\" href=\"/wp-content/themes/twenty/style.css\">\n"
b"</head>\n<body>\n"
b"<script src=\"/wp-includes/js/jquery/jquery.min.js\"></script>\n"
b"<p>Hola mundo desde wp-content.</p>\n"
b"</body>\n</html>\n"
)
class _WPHandler(http.server.BaseHTTPRequestHandler):
"""Handler que finge ser un WordPress detras de nginx + PHP."""
# Silencia el logging del server a stderr durante el test.
def log_message(self, *args, **kwargs): # noqa: D102
pass
def do_GET(self): # noqa: N802 - firma impuesta por BaseHTTPRequestHandler
self.send_response(200)
self.send_header("Server", "nginx/1.24.0")
self.send_header("X-Powered-By", "PHP/8.2.10")
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(_WP_HTML)))
self.end_headers()
self.wfile.write(_WP_HTML)
def _start_wp_server() -> tuple[socketserver.TCPServer, int, threading.Thread]:
"""Levanta un HTTPServer efimero en 127.0.0.1 que sirve el HTML WordPress.
Returns:
(httpd, port, thread). El caller debe llamar httpd.shutdown() al final.
"""
httpd = http.server.HTTPServer(("127.0.0.1", 0), _WPHandler)
port = httpd.server_address[1]
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
return httpd, port, t
# --- 1. Golden: fingerprint contra un servidor WordPress/nginx/PHP local ------
def test_golden_fingerprint_servidor_local_wordpress_nginx():
"""Detecta WordPress (CMS), nginx (servidor) y PHP en el HTML/headers locales."""
httpd, port, thread = _start_wp_server()
try:
result = fingerprint_web_stack(
f"http://127.0.0.1:{port}/",
timeout_s=5.0,
save=False,
)
assert result["status"] == "ok", result
assert result["status_code"] == 200, result
# No se archivo en OSINT (save=False).
assert result["saved"] is None, result
# Hubo al menos una tecnologia detectada.
assert result["count"] > 0, result
names = {t["name"] for t in result["technologies"]}
# WordPress por meta generator; nginx por cabecera Server.
assert "WordPress" in names, names
assert "nginx" in names, names
# by_category coherente con las tecnologias.
by_cat = result["by_category"]
assert "WordPress" in by_cat.get("cms", []), by_cat
assert "nginx" in by_cat.get("web-server", []), by_cat
# server y title vienen del fetch.
assert "nginx" in (result["server"] or ""), result["server"]
assert "WordPress" in (result["title"] or ""), result["title"]
# raw es la tabla legible con cabeceras y columnas.
raw = result["raw"]
assert isinstance(raw, str)
assert "TECHNOLOGY" in raw
assert "WordPress" in raw
assert "nginx" in raw
assert str(port) in raw # la URL solicitada aparece en la cabecera
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2.0)
# --- 2. save=False: corre fetch + matching pero NO archiva en OSINT -----------
def test_save_false_no_archiva_osint():
"""save=False: technologies poblado pero el sink nunca se invoca."""
save_called = {"n": 0}
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {"status": "ok"}
httpd, port, thread = _start_wp_server()
original_save = mod.save_scan_to_osint
mod.save_scan_to_osint = fake_save
try:
result = fingerprint_web_stack(
f"http://127.0.0.1:{port}/",
timeout_s=5.0,
save=False,
)
finally:
mod.save_scan_to_osint = original_save
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2.0)
assert result["status"] == "ok", result
assert result["count"] > 0, result
assert result["saved"] is None, result
# El sink nunca se invoco con save=False.
assert save_called["n"] == 0, save_called
# --- 3. Error path: el fetch HTTP falla -> error sin red externa --------------
def test_fetch_fallido_propaga_error_sin_red():
"""Host que no resuelve: fetch_http_fingerprint da error y el pipeline lo propaga."""
save_called = {"n": 0}
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {"status": "ok"}
# Parcheamos el sink: aunque save=True, con fetch fallido no debe invocarse.
original_save = mod.save_scan_to_osint
mod.save_scan_to_osint = fake_save
try:
result = fingerprint_web_stack(
"http://nohost.invalid.tld.example/",
timeout_s=2.0,
save=True,
)
finally:
mod.save_scan_to_osint = original_save
assert result["status"] == "error", result
assert result["stage"] == "fetch", result
assert result["fetch"]["status"] == "error", result
# No se intento archivar nada.
assert save_called["n"] == 0, save_called