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,170 @@
"""Tests para nmap_scan (wrapper nmap, estilo dict sin excepciones).
SIN red: nunca ejecuta nmap real. subprocess.run se monkeypatchea para que el
guard y el parseo de XML se prueben de forma determinista y offline.
"""
import os
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(__file__))
from nmap_scan import _parse_xml, _target_is_private, nmap_scan
# XML fixture minimo de nmap: un host up con el puerto 22/tcp open (ssh).
NMAP_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<nmaprun scanner="nmap">
<host>
<status state="up" reason="syn-ack"/>
<address addr="192.168.1.10" addrtype="ipv4"/>
<ports>
<port protocol="tcp" portid="22">
<state state="open" reason="syn-ack"/>
<service name="ssh" product="OpenSSH" version="8.9"/>
</port>
<port protocol="tcp" portid="80">
<state state="closed" reason="reset"/>
<service name="http"/>
</port>
</ports>
</host>
</nmaprun>
"""
def _make_fake_run(write_xml: bool = True, returncode: int = 0):
"""Devuelve un fake de subprocess.run que escribe el XML fixture en la ruta
que sigue a '-oX' en el comando y devuelve un CompletedProcess."""
def fake_run(cmd, *args, **kwargs):
if write_xml:
idx = cmd.index("-oX")
xml_path = cmd[idx + 1]
with open(xml_path, "w", encoding="utf-8") as fh:
fh.write(NMAP_XML)
return subprocess.CompletedProcess(
args=cmd, returncode=returncode, stdout="raw nmap output", stderr=""
)
return fake_run
def _fail_if_called(*args, **kwargs):
"""subprocess.run que falla el test si se invoca (el guard NO debe ejecutar nmap)."""
raise AssertionError("subprocess.run no debe llamarse cuando el guard rechaza el target")
# --- 1. _parse_xml: golden ---------------------------------------------------
def test_parse_xml_extrae_puertos_abiertos_y_hosts_up():
"""Escribe el XML fixture a un tmp y comprueba el parseo."""
fd, xml_path = tempfile.mkstemp(suffix=".xml")
os.close(fd)
try:
with open(xml_path, "w", encoding="utf-8") as fh:
fh.write(NMAP_XML)
open_ports, hosts_up, host_status = _parse_xml(xml_path)
assert hosts_up == ["192.168.1.10"]
assert host_status == "up"
# Solo el puerto 22 esta open; el 80 esta closed y se descarta.
assert len(open_ports) == 1
p = open_ports[0]
assert p["port"] == 22
assert p["proto"] == "tcp"
assert p["state"] == "open"
assert p["service"] == "ssh"
assert p["product"] == "OpenSSH"
assert p["version"] == "8.9"
finally:
os.remove(xml_path)
# --- 2. Guard error path: publico sin confirm no ejecuta nmap ----------------
def test_guard_publico_sin_confirm_rechaza_y_no_ejecuta(monkeypatch):
"""8.8.8.8 (publico) sin confirm -> error + needs_confirm, sin tocar subprocess."""
monkeypatch.setattr(subprocess, "run", _fail_if_called)
result = nmap_scan("8.8.8.8")
assert result["status"] == "error"
assert result["needs_confirm"] is True
assert "8.8.8.8" in result["error"]
# --- 3. Guard privado OK: procede y parsea -----------------------------------
def test_guard_privado_procede_y_parsea(monkeypatch):
"""192.168.1.10 (privado) sin confirm -> procede; XML parseado."""
monkeypatch.setattr(subprocess, "run", _make_fake_run())
result = nmap_scan("192.168.1.10")
assert result["status"] == "ok"
assert result["hosts_up"] == ["192.168.1.10"]
assert len(result["open_ports"]) == 1
assert result["open_ports"][0]["port"] == 22
assert result["raw"] == "raw nmap output"
# --- 4. Guard confirm=True sobre publico procede -----------------------------
def test_guard_confirm_true_sobre_publico_procede(monkeypatch):
"""8.8.8.8 (publico) con confirm=True -> procede."""
monkeypatch.setattr(subprocess, "run", _make_fake_run())
result = nmap_scan("8.8.8.8", confirm=True)
assert result["status"] == "ok"
assert len(result["open_ports"]) == 1
# --- 5. Guard allowlist: target autorizado procede ---------------------------
def test_guard_allowlist_procede(monkeypatch):
"""scanme.nmap.org en allowlist -> procede sin confirm."""
monkeypatch.setattr(subprocess, "run", _make_fake_run())
result = nmap_scan("scanme.nmap.org", allowlist=["scanme.nmap.org"])
assert result["status"] == "ok"
# --- 6. Errores de validacion ------------------------------------------------
def test_perfil_invalido_devuelve_error(monkeypatch):
"""Un perfil no listado -> status error, sin ejecutar nmap."""
monkeypatch.setattr(subprocess, "run", _fail_if_called)
result = nmap_scan("192.168.1.10", profile="noexiste")
assert result["status"] == "error"
assert "invalido" in result["error"]
def test_target_vacio_devuelve_error(monkeypatch):
"""Target vacio -> status error, sin ejecutar nmap."""
monkeypatch.setattr(subprocess, "run", _fail_if_called)
result = nmap_scan("")
assert result["status"] == "error"
assert "vacio" in result["error"]
# --- 7. _target_is_private: clasificacion ------------------------------------
def test_target_is_private_clasifica():
"""Privados/local -> True; publico -> False; hostname publico -> None."""
assert _target_is_private("10.0.0.1") is True
assert _target_is_private("127.0.0.1") is True
assert _target_is_private("192.168.0.0/24") is True
assert _target_is_private("8.8.8.8") is False
assert _target_is_private("localhost") is True
assert _target_is_private("foo.local") is True
assert _target_is_private("example.com") is None