935008ec3f
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>
109 lines
3.7 KiB
Python
109 lines
3.7 KiB
Python
"""Tests para fetch_http_fingerprint.
|
|
|
|
Levanta un http.server.HTTPServer local en 127.0.0.1 en un puerto efimero,
|
|
servido por un thread, con headers fake (Server, X-Powered-By, Set-Cookie) y
|
|
un HTML con <title>Hola</title>. NO toca red externa.
|
|
"""
|
|
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
from fetch_http_fingerprint import fetch_http_fingerprint
|
|
|
|
_HTML = b"<!doctype html><html><head><title>Hola</title></head><body>ok</body></html>"
|
|
|
|
|
|
class _FakeHandler(BaseHTTPRequestHandler):
|
|
def do_GET(self): # noqa: N802 (firma de BaseHTTPRequestHandler)
|
|
self.send_response(200)
|
|
self.send_header("Server", "TestServer/1.0")
|
|
self.send_header("X-Powered-By", "PHP/8.1")
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Set-Cookie", "PHPSESSID=secret_value_no_capturar; Path=/")
|
|
self.end_headers()
|
|
self.wfile.write(_HTML)
|
|
|
|
def log_message(self, *args): # silencia el logging del server en los tests
|
|
pass
|
|
|
|
|
|
def _start_server():
|
|
server = HTTPServer(("127.0.0.1", 0), _FakeHandler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
return server, thread
|
|
|
|
|
|
def test_status_ok_y_status_code_200():
|
|
server, thread = _start_server()
|
|
try:
|
|
port = server.server_address[1]
|
|
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
|
|
assert res["status"] == "ok", res
|
|
assert res["status_code"] == 200, res["status_code"]
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
def test_headers_normalizados_lowercase():
|
|
server, thread = _start_server()
|
|
try:
|
|
port = server.server_address[1]
|
|
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
|
|
assert res["headers"]["server"] == "TestServer/1.0", res["headers"]
|
|
assert res["server"] == "TestServer/1.0", res["server"]
|
|
assert res["headers"]["x-powered-by"] == "PHP/8.1", res["headers"]
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
def test_cookies_solo_nombres_no_valores():
|
|
server, thread = _start_server()
|
|
try:
|
|
port = server.server_address[1]
|
|
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
|
|
assert "PHPSESSID" in res["cookies"], res["cookies"]
|
|
# El valor sensible NUNCA debe aparecer en la salida.
|
|
assert "secret_value_no_capturar" not in res["raw"], "valor de cookie filtrado en raw"
|
|
assert all("=" not in c for c in res["cookies"]), res["cookies"]
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
def test_title_extraido():
|
|
server, thread = _start_server()
|
|
try:
|
|
port = server.server_address[1]
|
|
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
|
|
assert res["title"] == "Hola", res["title"]
|
|
assert res["html_len"] == len(_HTML), res["html_len"]
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
def test_url_vacia_devuelve_error():
|
|
res = fetch_http_fingerprint("")
|
|
assert res["status"] == "error", res
|
|
assert "url vacia" in res["error"], res["error"]
|
|
|
|
|
|
def test_host_inexistente_devuelve_error_sin_lanzar():
|
|
# Puerto cerrado en loopback: conexion rechazada, debe devolver error, no lanzar.
|
|
res = fetch_http_fingerprint("http://127.0.0.1:1/")
|
|
assert res["status"] == "error", res
|
|
assert res["url"] == "http://127.0.0.1:1/", res
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_status_ok_y_status_code_200()
|
|
test_headers_normalizados_lowercase()
|
|
test_cookies_solo_nombres_no_valores()
|
|
test_title_extraido()
|
|
test_url_vacia_devuelve_error()
|
|
test_host_inexistente_devuelve_error_sin_lanzar()
|
|
print("all tests passed")
|