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,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
|
||||
Reference in New Issue
Block a user