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,121 @@
|
||||
---
|
||||
name: fingerprint_web_stack
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def fingerprint_web_stack(url: str, timeout_s: float = 15.0, verify_tls: bool = True, max_html_bytes: int = 500_000, save: bool = True) -> dict"
|
||||
description: "One-shot que detecta la tecnologia web (stack tecnologico estilo Wappalyzer) de una URL: hace el fetch HTTP de las senales (fetch_http_fingerprint) y matchea las firmas (detect_web_tech), devolviendo las tecnologias detectadas — servidor, lenguaje, CMS, framework web, frameworks JS, librerias, analytics, CDN, e-commerce, WAF — con categoria, version y confidence. Reemplaza el patron fetch_http_fingerprint -> detect_web_tech por una sola llamada. El equivalente registry de Wappalyzer / whatweb / un fingerprint de stack de una url. Opcionalmente archiva la evidencia (tabla TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE) en OSINT. Util para reconocimiento web, auditoria de superficie y averiguar que CMS framework servidor usa un sitio."
|
||||
tags: [recon, web-recon, pipelines, cybersecurity, fingerprint, wappalyzer, web-tech, sink]
|
||||
uses_functions:
|
||||
- fetch_http_fingerprint_py_cybersecurity
|
||||
- detect_web_tech_py_cybersecurity
|
||||
- save_scan_to_osint_py_cybersecurity
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: url
|
||||
desc: "URL del sitio objetivo (ej. https://example.com). Sin esquema se asume https:// con fallback a http://, igual que fetch_http_fingerprint."
|
||||
- name: timeout_s
|
||||
desc: "Timeout de la peticion HTTP en segundos. Default 15.0. Se pasa tal cual a fetch_http_fingerprint."
|
||||
- name: verify_tls
|
||||
desc: "Si False, no verifica el certificado TLS (inseguro, solo para hosts propios con cert self-signed). Default True. Se pasa a fetch_http_fingerprint."
|
||||
- name: max_html_bytes
|
||||
desc: "Corta el HTML leido a este tamano para no descargar megas. Default 500_000 (500 KB). Se pasa a fetch_http_fingerprint."
|
||||
- name: save
|
||||
desc: "Si True (default) archiva la evidencia en OSINT via save_scan_to_osint con scan_type='web_tech' (target = host de la URL); si False solo ejecuta el fetch + matching y no toca el vault ni el service osint_db. Politica recon: todo scan se archiva. Si el sink falla, el resultado degrada sin romper (saved.status='error')."
|
||||
output: "dict con status ('ok'|'error'), url, final_url (tras redirects), status_code (int), server (cabecera Server o ''), title (titulo de la pagina o ''), technologies (lista de dicts con name, category, version, confidence, evidence — tal cual de detect_web_tech), by_category (dict categoria -> lista de nombres), count (int), saved (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False) y raw (tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE con cabecera de url/status/server/title). Si el fetch HTTP falla (host no resuelve, conexion rechazada, timeout) -> {status:error, stage:fetch, url:..., fetch:<dict>}. Nunca lanza."
|
||||
tested: true
|
||||
tests: ["test_golden_fingerprint_servidor_local_wordpress_nginx", "test_save_false_no_archiva_osint", "test_fetch_fallido_propaga_error_sin_red"]
|
||||
test_file_path: "python/functions/pipelines/fingerprint_web_stack_test.py"
|
||||
file_path: "python/functions/pipelines/fingerprint_web_stack.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from pipelines.fingerprint_web_stack import fingerprint_web_stack
|
||||
|
||||
# Fingerprint del stack tecnologico de un sitio, en 1 paso (sin archivar).
|
||||
r = fingerprint_web_stack("https://example.com", save=False)
|
||||
print(r["status"]) # "ok"
|
||||
print(r["server"]) # "nginx/1.24.0"
|
||||
print(r["count"]) # 5
|
||||
for t in r["technologies"]:
|
||||
print(t["name"], t["category"], t["version"], t["confidence"])
|
||||
# WordPress cms 6.4 high
|
||||
# nginx web-server 1.24.0 high
|
||||
# PHP programming-language medium
|
||||
print(r["by_category"]) # {"cms": ["WordPress"], "web-server": ["nginx"], ...}
|
||||
```
|
||||
|
||||
```python
|
||||
from pipelines.fingerprint_web_stack import fingerprint_web_stack
|
||||
|
||||
# Con archivado en OSINT (default): deja una nota en el vault + POST al osint_db.
|
||||
r = fingerprint_web_stack("https://midominio.example")
|
||||
print(r["saved"]["note_path"]) # dominios/midominio.example/recon/web_tech-....md
|
||||
```
|
||||
|
||||
```bash
|
||||
# Por CLI: detecta el stack de un sitio.
|
||||
./fn run fingerprint_web_stack https://example.com
|
||||
# Flags: --no-save (no archiva OSINT), --no-verify-tls (cert self-signed, inseguro).
|
||||
./fn run fingerprint_web_stack https://example.com --no-save
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras en UN solo paso saber que tecnologia usa un sitio web (servidor,
|
||||
CMS, frameworks JS, lenguaje, analytics, CDN, WAF) — el equivalente registry de
|
||||
Wappalyzer. Reemplaza el patron `fetch_http_fingerprint` -> `detect_web_tech`
|
||||
(un fetch + un matching). Tipico para: reconocimiento web inicial de un
|
||||
objetivo, averiguar el CMS/framework de un sitio antes de un pentest
|
||||
autorizado, auditar la superficie tecnologica de tus propios dominios, o
|
||||
enriquecer una investigacion OSINT con el stack de un host.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Fetch estatico: NO ejecuta JavaScript.** Solo ve el HTML inicial que devuelve
|
||||
el servidor. Las SPAs que montan el framework (React/Vue/Angular/Svelte) en
|
||||
runtime suelen servir un HTML casi vacio, asi que esos frameworks pueden NO
|
||||
detectarse. Para sitios JS-pesados, un fingerprint con navegador real (CDP)
|
||||
veria mas; este pipeline es la version sin navegador.
|
||||
- **La tabla de firmas es un subconjunto de Wappalyzer**, no exhaustiva. Un
|
||||
tecnologia no listada en `detect_web_tech` no aparecera aunque este presente.
|
||||
Para ampliar cobertura, anade entradas a `SIGNATURES` en `detect_web_tech`.
|
||||
- **`verify_tls=False` es inseguro**: desactiva la verificacion del certificado
|
||||
(MITM posible). Usalo solo contra hosts propios con cert self-signed.
|
||||
- **Un WAF/anti-bot puede devolver un challenge** (Cloudflare, Imperva...) en vez
|
||||
del sitio real: en ese caso las tecnologias detectadas seran las del WAF y el
|
||||
HTML del challenge, no las del sitio de fondo.
|
||||
- **save=True escribe en el vault OSINT** (`~/Obsidian/osint`) y hace POST al
|
||||
service `osint_db` (`http://127.0.0.1:8771`). Si el service esta caido,
|
||||
`save_scan_to_osint` degrada a solo-nota (`saved.registered=False` con
|
||||
`register_warning`); el pipeline no falla por eso.
|
||||
- **Autorizacion legal**: hacer fingerprint de hosts ajenos puede entrar en
|
||||
reconocimiento no autorizado. Respeta el scope y la autorizacion explicita;
|
||||
usalo sobre objetivos propios o consentidos.
|
||||
- **Pipeline impuro**: hace red (fetch HTTP) y, con `save=True`, FS/HTTP (vault +
|
||||
service). No es determinista entre ejecuciones.
|
||||
- Si el fetch HTTP falla del todo (`status != "ok"`: host no resuelve, conexion
|
||||
rechazada, timeout), el pipeline devuelve `{"status":"error","stage":"fetch",...}`
|
||||
y **no** intenta matchear firmas ni archivar nada. Un 403/404/500 NO es fallo
|
||||
de fetch: sigue siendo senal de fingerprint y se procesa con su status_code.
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline que compone 3 funciones atomicas del dominio `cybersecurity`. No
|
||||
reimplementa logica de fetch, matching de firmas ni persistencia: solo orquesta
|
||||
`fetch_http_fingerprint` (recoleccion de senales, impura) + `detect_web_tech`
|
||||
(matching de firmas, pura) y delega el guardado en `save_scan_to_osint`. El
|
||||
`raw` de evidencia incluye una cabecera con url/final_url/status_code/server/
|
||||
title y una tabla TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE; nunca embebe el HTML
|
||||
entero ni valores de cookie (las cookies de `fetch_http_fingerprint` ya son solo
|
||||
nombres). El `target` para el archivado OSINT se deriva del host de la URL
|
||||
(`urllib.parse.urlparse(...).hostname`). Nunca lanza excepciones: todo fallo se
|
||||
refleja en la clave `status` del dict devuelto.
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Pipeline fingerprint_web_stack.
|
||||
|
||||
One-shot que materializa el flujo "averiguar la tecnologia web (stack) de una
|
||||
URL" estilo Wappalyzer: hace el fetch HTTP de las senales (cabeceras, HTML,
|
||||
cookies, titulo, servidor) y matchea las firmas para devolver las tecnologias
|
||||
detectadas (servidor, lenguaje, CMS, frameworks JS, librerias, analytics, CDN,
|
||||
e-commerce, WAF). Opcionalmente archiva la evidencia en OSINT.
|
||||
|
||||
Convierte el patron de 2 llamadas (fetch_http_fingerprint -> detect_web_tech)
|
||||
en una sola invocacion. Compone funciones del registry del dominio
|
||||
cybersecurity; no reescribe ninguna logica de fetch, matching de firmas ni
|
||||
persistencia.
|
||||
|
||||
Funciones del registry compuestas (importadas, no reimplementadas):
|
||||
fetch_http_fingerprint, detect_web_tech, save_scan_to_osint
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cybersecurity import (
|
||||
fetch_http_fingerprint,
|
||||
detect_web_tech,
|
||||
save_scan_to_osint,
|
||||
)
|
||||
|
||||
|
||||
def _build_raw(
|
||||
url: str,
|
||||
final_url: str,
|
||||
status_code: int,
|
||||
server: str,
|
||||
title: str,
|
||||
technologies: list[dict],
|
||||
) -> str:
|
||||
"""Construye una tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE para evidencia.
|
||||
|
||||
NO incluye el HTML entero ni valores de cookie: solo metadatos de respuesta y
|
||||
la matriz de tecnologias detectadas.
|
||||
|
||||
Args:
|
||||
url: URL solicitada.
|
||||
final_url: URL final tras redirects.
|
||||
status_code: codigo HTTP de la respuesta.
|
||||
server: cadena del servidor (cabecera Server), puede ser "".
|
||||
title: titulo de la pagina, puede ser "".
|
||||
technologies: lista de dicts de tecnologia (ver fingerprint_web_stack).
|
||||
|
||||
Returns:
|
||||
Bloque de texto multi-linea con cabecera y una fila por tecnologia.
|
||||
"""
|
||||
header_lines = [
|
||||
f"# fingerprint_web_stack {url}",
|
||||
"",
|
||||
f"url: {url}",
|
||||
f"final_url: {final_url}",
|
||||
f"status_code: {status_code}",
|
||||
f"server: {server or '-'}",
|
||||
f"title: {title or '-'}",
|
||||
"",
|
||||
]
|
||||
cols = f"{'TECHNOLOGY':<24}{'CATEGORY':<22}{'VERSION':<14}CONFIDENCE"
|
||||
lines = header_lines + [cols]
|
||||
for t in technologies:
|
||||
name = str(t.get("name", ""))
|
||||
category = str(t.get("category", ""))
|
||||
version = str(t.get("version") or "")
|
||||
confidence = str(t.get("confidence", ""))
|
||||
lines.append(f"{name:<24}{category:<22}{version:<14}{confidence}")
|
||||
if not technologies:
|
||||
lines.append("(no technologies detected)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _target_from_url(url: str, final_url: str) -> str:
|
||||
"""Deriva el target (host) para el archivado OSINT a partir de la URL.
|
||||
|
||||
Prefiere el host de la URL solicitada; si no se puede parsear, cae al host de
|
||||
la URL final tras redirects; si tampoco, devuelve la cadena cruda.
|
||||
|
||||
Args:
|
||||
url: URL solicitada.
|
||||
final_url: URL final tras redirects.
|
||||
|
||||
Returns:
|
||||
El host (sin esquema ni path), o la URL cruda si no se pudo extraer.
|
||||
"""
|
||||
for candidate in (url, final_url):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
host = urlparse(candidate).hostname
|
||||
except ValueError:
|
||||
host = None
|
||||
if host:
|
||||
return host
|
||||
return (url or final_url or "unknown").strip()
|
||||
|
||||
|
||||
def fingerprint_web_stack(
|
||||
url: str,
|
||||
timeout_s: float = 15.0,
|
||||
verify_tls: bool = True,
|
||||
max_html_bytes: int = 500_000,
|
||||
save: bool = True,
|
||||
) -> dict:
|
||||
"""Detecta la tecnologia web (stack) de una URL en un solo paso (estilo Wappalyzer).
|
||||
|
||||
Compone, en una sola invocacion:
|
||||
1. ``fetch_http_fingerprint(url, ...)`` para recoger las senales crudas de
|
||||
la respuesta (cabeceras, HTML, cookies, titulo, servidor).
|
||||
2. ``detect_web_tech(headers, html, cookies, final_url)`` (PURA) para
|
||||
matchear esas senales contra la tabla de firmas y obtener las
|
||||
tecnologias detectadas.
|
||||
3. Si ``save`` es True, archiva una tabla de evidencia en OSINT via
|
||||
``save_scan_to_osint`` con ``scan_type="web_tech"`` (target = host de la
|
||||
URL).
|
||||
|
||||
Nunca lanza excepciones: cualquier fallo se refleja en la clave ``status``
|
||||
del dict devuelto.
|
||||
|
||||
Args:
|
||||
url: URL objetivo. Sin esquema se asume https:// (fallback a http://),
|
||||
tal como hace fetch_http_fingerprint.
|
||||
timeout_s: timeout de la peticion HTTP en segundos. Default 15.0. Se pasa
|
||||
tal cual a fetch_http_fingerprint.
|
||||
verify_tls: si False, no verifica el certificado TLS (inseguro, solo para
|
||||
hosts propios con cert self-signed). Default True. Se pasa a
|
||||
fetch_http_fingerprint.
|
||||
max_html_bytes: corta el HTML leido a este tamano para no descargar megas.
|
||||
Default 500_000 (500 KB). Se pasa a fetch_http_fingerprint.
|
||||
save: si True (default) archiva la evidencia en OSINT via
|
||||
save_scan_to_osint con scan_type="web_tech"; si False solo ejecuta el
|
||||
fetch + matching y no toca el vault ni el service osint_db. Politica
|
||||
recon: todo scan se archiva. Si el sink falla, el resultado degrada
|
||||
sin romper (saved.status="error").
|
||||
|
||||
Returns:
|
||||
dict de estado. Nunca lanza.
|
||||
ok::
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"url": <url solicitada>,
|
||||
"final_url": <url tras redirects>,
|
||||
"status_code": int,
|
||||
"server": str, # cabecera Server, "" si no hay
|
||||
"title": str, # titulo de la pagina, "" si no hay
|
||||
"technologies": [ # tal cual de detect_web_tech
|
||||
{"name", "category", "version", "confidence", "evidence"},
|
||||
...
|
||||
],
|
||||
"by_category": {<categoria>: [<nombre>, ...], ...},
|
||||
"count": int,
|
||||
"saved": <dict de save_scan_to_osint> | None,
|
||||
"raw": "# fingerprint_web_stack ...\nTECHNOLOGY ...",
|
||||
}
|
||||
|
||||
error (el fetch HTTP fallo: host no resuelve, conexion rechazada,
|
||||
timeout)::
|
||||
|
||||
{"status": "error", "stage": "fetch", "url": <url>, "fetch": <dict>}
|
||||
"""
|
||||
# 1. Fetch de senales. Si el fetch falla del todo, propagamos sin continuar.
|
||||
fp = fetch_http_fingerprint(
|
||||
url,
|
||||
timeout_s=timeout_s,
|
||||
verify_tls=verify_tls,
|
||||
max_html_bytes=max_html_bytes,
|
||||
)
|
||||
if fp.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"stage": "fetch",
|
||||
"url": url,
|
||||
"fetch": fp,
|
||||
}
|
||||
|
||||
final_url = fp.get("final_url", "") or ""
|
||||
status_code = fp.get("status_code", 0)
|
||||
server = fp.get("server") or ""
|
||||
title = fp.get("title") or ""
|
||||
|
||||
# 2. Matching de firmas (puro): no toca red, solo aplica regex deterministas.
|
||||
detection = detect_web_tech(
|
||||
fp.get("headers") or {},
|
||||
html=fp.get("html") or "",
|
||||
cookies=fp.get("cookies") or [],
|
||||
final_url=final_url,
|
||||
)
|
||||
technologies = detection.get("technologies", [])
|
||||
by_category = detection.get("by_category", {})
|
||||
count = detection.get("count", len(technologies))
|
||||
|
||||
raw = _build_raw(url, final_url, status_code, server, title, technologies)
|
||||
|
||||
# 3. Archiva la evidencia en OSINT si procede (degrada sin romper).
|
||||
saved = None
|
||||
if save:
|
||||
target = _target_from_url(url, final_url)
|
||||
summary = {
|
||||
"count": count,
|
||||
"by_category": by_category,
|
||||
"server": server,
|
||||
"status_code": status_code,
|
||||
}
|
||||
saved = save_scan_to_osint(
|
||||
target,
|
||||
"web_tech",
|
||||
raw,
|
||||
summary=summary,
|
||||
tool="fingerprint_web_stack",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"url": url,
|
||||
"final_url": final_url,
|
||||
"status_code": status_code,
|
||||
"server": server,
|
||||
"title": title,
|
||||
"technologies": technologies,
|
||||
"by_category": by_category,
|
||||
"count": count,
|
||||
"saved": saved,
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _parse_cli(argv: list[str]) -> dict:
|
||||
"""Parsea los args de CLI: <url> [--no-save] [--no-verify-tls].
|
||||
|
||||
Devuelve un dict de kwargs para fingerprint_web_stack.
|
||||
"""
|
||||
positional: list[str] = []
|
||||
save = True
|
||||
verify_tls = True
|
||||
|
||||
for arg in argv:
|
||||
if arg == "--no-save":
|
||||
save = False
|
||||
elif arg == "--no-verify-tls":
|
||||
verify_tls = False
|
||||
else:
|
||||
positional.append(arg)
|
||||
|
||||
return {"positional": positional, "save": save, "verify_tls": verify_tls}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
parsed = _parse_cli(sys.argv[1:])
|
||||
positional = parsed["positional"]
|
||||
target_url = positional[0] if len(positional) >= 1 else "https://example.com"
|
||||
|
||||
try:
|
||||
result = fingerprint_web_stack(
|
||||
target_url,
|
||||
verify_tls=parsed["verify_tls"],
|
||||
save=parsed["save"],
|
||||
)
|
||||
print("status:", result.get("status"))
|
||||
if result.get("status") == "ok":
|
||||
print(f"url: {result['url']} -> {result['final_url']} ({result['status_code']})")
|
||||
print("server:", result["server"] or "-")
|
||||
print("--- technologies ---")
|
||||
print(result["raw"])
|
||||
saved = result.get("saved") or {}
|
||||
if saved:
|
||||
print("note_path:", saved.get("note_path"))
|
||||
print("registered:", saved.get("registered"))
|
||||
else:
|
||||
print("error:", result.get("fetch", {}).get("error"))
|
||||
except Exception as exc: # noqa: BLE001 - smoke nunca debe romper
|
||||
print("smoke exception (tolerada):", repr(exc))
|
||||
@@ -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
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: recon_osint
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "def recon_osint(target: str, scan_type: str = 'whois', save: bool = True, profile: str = 'quick', record_types: list[str] | None = None, count: int = 4, max_hops: int = 30, timeout_s: int | None = None, confirm: bool = False) -> dict"
|
||||
description: "One-shot que ejecuta un escaneo de red atomico (whois/rdap/dns/ping/traceroute/nmap) y SIEMPRE lo archiva en OSINT. Materializa la politica 'todo escaneo se guarda en osint': convierte el patron de 2 llamadas (scan atomico + sink) en 1 sola."
|
||||
tags: [recon, osint, pipeline, cybersecurity, osint-passive, sink]
|
||||
uses_functions:
|
||||
- whois_lookup_py_cybersecurity
|
||||
- rdap_lookup_py_cybersecurity
|
||||
- dns_records_py_cybersecurity
|
||||
- ping_host_py_cybersecurity
|
||||
- traceroute_host_py_cybersecurity
|
||||
- nmap_scan_py_cybersecurity
|
||||
- save_scan_to_osint_py_cybersecurity
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: target
|
||||
desc: "Dominio, host o IP objetivo del escaneo (ej. google.com, scanme.nmap.org, 8.8.8.8)."
|
||||
- name: scan_type
|
||||
desc: "Tipo de escaneo a ejecutar. Uno de: whois, rdap, dns, ping, traceroute, nmap. Default 'whois'."
|
||||
- name: save
|
||||
desc: "Si True (default) archiva el resultado en OSINT via save_scan_to_osint; si False solo ejecuta el escaneo y no toca el vault."
|
||||
- name: profile
|
||||
desc: "Perfil de nmap (solo aplica a scan_type='nmap'): quick, full-tcp, vuln, etc. Default 'quick'."
|
||||
- name: record_types
|
||||
desc: "Lista de tipos de registro DNS a consultar (solo scan_type='dns'), ej. ['A','MX','TXT']. None usa los defaults del atomico."
|
||||
- name: count
|
||||
desc: "Numero de paquetes ICMP a enviar (solo scan_type='ping'). Default 4."
|
||||
- name: max_hops
|
||||
desc: "Numero maximo de saltos a sondear (solo scan_type='traceroute'). Default 30."
|
||||
- name: timeout_s
|
||||
desc: "Timeout en segundos. Si se pasa, se propaga al escaneo atomico; None deja que cada atomico use su default. Sube este valor para nmap full-tcp/vuln."
|
||||
- name: confirm
|
||||
desc: "Confirmacion explicita para el escaneo activo de nmap (solo aplica a scan_type='nmap'); se propaga a nmap_scan(confirm=...). Default False: nmap rechaza targets publicos/desconocidos no autorizados (status error + needs_confirm). Pasar True solo con autorizacion. Se ignora para whois/rdap/dns/ping/traceroute. CLI: flag --confirm (y --allowlist a,b,c que activa confirm si el target esta autorizado)."
|
||||
output: "dict con status ('ok'|'error'), target, scan_type, scan (dict crudo del atomico) y osint (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False). scan_type invalido -> {status:error, stage:validate, valid:[...]}. Escaneo fallido -> {status:error, stage:scan, scan:<dict>} sin intentar guardar. Nunca lanza."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/pipelines/recon_osint.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from pipelines.recon_osint import recon_osint
|
||||
|
||||
# WHOIS de un dominio: ejecuta el lookup y lo archiva en OSINT (1 paso).
|
||||
r = recon_osint("google.com", "whois")
|
||||
print(r["status"]) # "ok"
|
||||
print(r["osint"]["note_path"]) # ruta de la nota creada en el vault osint
|
||||
```
|
||||
|
||||
```python
|
||||
from pipelines.recon_osint import recon_osint
|
||||
|
||||
# Escaneo nmap rapido de un host autorizado, archivado en OSINT.
|
||||
r = recon_osint("scanme.nmap.org", "nmap", profile="quick")
|
||||
print(r["scan"]["open_ports"]) # puertos abiertos detectados
|
||||
print(r["osint"]["registered"]) # True si el service osint_db lo registro
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Para lanzar un escaneo de red y archivarlo en OSINT en un solo paso. Usalo en
|
||||
vez de llamar el atomico (`whois_lookup`, `nmap_scan`, ...) + `save_scan_to_osint`
|
||||
por separado. Garantiza que el resultado queda en el vault: es la materializacion
|
||||
de la politica "todo escaneo que lancemos se guarda en osint".
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Hereda los gotchas de cada escaneo atomico que compone:
|
||||
- **nmap**: guard anti-escaneo no autorizado — un target publico/desconocido
|
||||
se rechaza (status error + needs_confirm) salvo que pases `confirm=True`
|
||||
(o, por CLI, `--confirm` / `--allowlist`). Los privados/local proceden sin
|
||||
confirm. Solo escanea objetivos para los que tengas autorizacion legal;
|
||||
escaneos sin permiso pueden ser ilegales. Requiere `nmap` instalado.
|
||||
- **ping / traceroute**: el ICMP puede estar filtrado por firewalls; un
|
||||
`loss_pct` 100% o pocos `hops` no implican que el host este caido, solo que
|
||||
no responde a ICMP.
|
||||
- **whois / rdap**: dependen de la disponibilidad y rate-limit del servidor
|
||||
WHOIS/RDAP del TLD; respuestas truncadas o vacias son posibles.
|
||||
- **dns**: resuelve contra el resolver del sistema; resultados varian segun el
|
||||
DNS configurado.
|
||||
- Requiere el service `osint_db` vivo (`http://127.0.0.1:8771`) para el registro
|
||||
estructurado; si no responde, `save_scan_to_osint` degrada a guardar solo la
|
||||
nota en el vault (sin entrada en DuckDB). El pipeline no falla por eso.
|
||||
- Para nmap largos (`full-tcp`, `vuln`) pasa `profile` + un `timeout_s` alto y
|
||||
considera lanzarlo en background; el default `timeout_s` del atomico nmap es
|
||||
amplio (1800s) pero un escaneo completo puede excederlo.
|
||||
- Si el escaneo atomico falla (`status == "error"`), el pipeline devuelve
|
||||
`{"status":"error","stage":"scan",...}` y **no** intenta guardar nada en OSINT.
|
||||
- Pipeline impuro: hace red (escaneos) y FS/HTTP (vault + service). No es
|
||||
determinista entre ejecuciones.
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline que compone 7 funciones atomicas del dominio `cybersecurity`. No
|
||||
reimplementa logica de escaneo ni de persistencia; solo despacha, normaliza un
|
||||
`summary` por tipo de escaneo y delega el guardado. Nunca lanza excepciones:
|
||||
todo fallo se refleja en la clave `status` del dict devuelto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.1.0 (2026-06-14) — param `confirm` propagado a `nmap_scan(confirm=...)`
|
||||
(solo scan_type='nmap') para el guard anti-escaneo-no-autorizado; CLI gana
|
||||
flag `--confirm` y `--allowlist a,b,c` (activa confirm si el target esta
|
||||
autorizado). Sin el flag, confirm=False (compatibilidad intacta).
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Pipeline recon_osint.
|
||||
|
||||
One-shot que ejecuta UN escaneo de red atomico y SIEMPRE lo archiva en OSINT.
|
||||
|
||||
Materializa la politica "todo escaneo que lancemos se guarda en osint":
|
||||
convierte el patron de dos llamadas (scan atomico + sink a OSINT) en una sola
|
||||
invocacion. Compone funciones del registry del dominio cybersecurity; no
|
||||
reescribe ninguna logica de escaneo ni de persistencia.
|
||||
|
||||
Funciones del registry compuestas (importadas, no reimplementadas):
|
||||
whois_lookup, rdap_lookup, dns_records, ping_host, traceroute_host,
|
||||
nmap_scan, save_scan_to_osint
|
||||
"""
|
||||
|
||||
from cybersecurity import (
|
||||
whois_lookup,
|
||||
rdap_lookup,
|
||||
dns_records,
|
||||
ping_host,
|
||||
traceroute_host,
|
||||
nmap_scan,
|
||||
save_scan_to_osint,
|
||||
)
|
||||
|
||||
VALID_SCAN_TYPES = ("whois", "rdap", "dns", "ping", "traceroute", "nmap")
|
||||
|
||||
|
||||
def recon_osint(
|
||||
target: str,
|
||||
scan_type: str = "whois",
|
||||
save: bool = True,
|
||||
profile: str = "quick",
|
||||
record_types: list[str] | None = None,
|
||||
count: int = 4,
|
||||
max_hops: int = 30,
|
||||
timeout_s: int | None = None,
|
||||
confirm: bool = False,
|
||||
) -> dict:
|
||||
"""Ejecuta un escaneo de red atomico y lo archiva en OSINT en un solo paso.
|
||||
|
||||
Despacha al escaneo atomico correspondiente segun ``scan_type``, captura su
|
||||
resultado y, si el escaneo tuvo exito y ``save`` es True, lo guarda en el
|
||||
vault OSINT via ``save_scan_to_osint``. Nunca lanza excepciones: cualquier
|
||||
fallo se refleja en la clave ``status`` del dict devuelto.
|
||||
|
||||
Args:
|
||||
target: dominio, host o IP objetivo del escaneo.
|
||||
scan_type: tipo de escaneo, uno de whois, rdap, dns, ping, traceroute,
|
||||
nmap.
|
||||
save: si True (default) archiva el resultado en OSINT; si False solo
|
||||
ejecuta el escaneo.
|
||||
profile: perfil de nmap (solo aplica a scan_type='nmap'). Ej. quick,
|
||||
full-tcp, vuln.
|
||||
record_types: tipos de registro DNS a consultar (solo scan_type='dns').
|
||||
None usa los defaults de dns_records.
|
||||
count: numero de paquetes ICMP (solo scan_type='ping').
|
||||
max_hops: numero maximo de saltos (solo scan_type='traceroute').
|
||||
timeout_s: timeout en segundos. Si se pasa, se propaga al escaneo
|
||||
atomico; si es None, cada atomico usa su default.
|
||||
confirm: confirmacion explicita para el escaneo activo de nmap (solo
|
||||
aplica a scan_type='nmap'). Por defecto False: nmap rechaza targets
|
||||
publicos/desconocidos no autorizados. Pasar True solo cuando el
|
||||
escaneo este autorizado. Se ignora para los demas scan_type.
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
- status: 'ok' | 'error'.
|
||||
- target: el objetivo escaneado.
|
||||
- scan_type: el tipo de escaneo ejecutado.
|
||||
- scan: dict crudo devuelto por la funcion atomica.
|
||||
- osint: dict devuelto por save_scan_to_osint, o None si save=False.
|
||||
En caso de scan_type invalido devuelve
|
||||
{"status": "error", "stage": "validate", ...} con la lista de tipos
|
||||
validos. Si el escaneo falla devuelve
|
||||
{"status": "error", "stage": "scan", "scan": <dict>} sin intentar guardar.
|
||||
"""
|
||||
if scan_type not in VALID_SCAN_TYPES:
|
||||
return {
|
||||
"status": "error",
|
||||
"stage": "validate",
|
||||
"target": target,
|
||||
"scan_type": scan_type,
|
||||
"error": f"invalid scan_type '{scan_type}'",
|
||||
"valid": list(VALID_SCAN_TYPES),
|
||||
}
|
||||
|
||||
# 1. Despacho al escaneo atomico correspondiente.
|
||||
if scan_type == "whois":
|
||||
kwargs = {} if timeout_s is None else {"timeout_s": timeout_s}
|
||||
scan = whois_lookup(target, **kwargs)
|
||||
elif scan_type == "rdap":
|
||||
kwargs = {} if timeout_s is None else {"timeout_s": timeout_s}
|
||||
scan = rdap_lookup(target, **kwargs)
|
||||
elif scan_type == "dns":
|
||||
kwargs = {"record_types": record_types}
|
||||
if timeout_s is not None:
|
||||
kwargs["timeout_s"] = timeout_s
|
||||
scan = dns_records(target, **kwargs)
|
||||
elif scan_type == "ping":
|
||||
kwargs = {"count": count}
|
||||
if timeout_s is not None:
|
||||
kwargs["timeout_s"] = timeout_s
|
||||
scan = ping_host(target, **kwargs)
|
||||
elif scan_type == "traceroute":
|
||||
kwargs = {"max_hops": max_hops}
|
||||
if timeout_s is not None:
|
||||
kwargs["timeout_s"] = timeout_s
|
||||
scan = traceroute_host(target, **kwargs)
|
||||
else: # nmap
|
||||
kwargs = {"profile": profile, "confirm": confirm}
|
||||
if timeout_s is not None:
|
||||
kwargs["timeout_s"] = timeout_s
|
||||
scan = nmap_scan(target, **kwargs)
|
||||
|
||||
# 2. Si el escaneo fallo, no intentamos guardar.
|
||||
if scan.get("status") == "error":
|
||||
return {"status": "error", "stage": "scan", "scan": scan}
|
||||
|
||||
# 3. Construye el summary segun el tipo de escaneo.
|
||||
if scan_type == "whois":
|
||||
summary = {
|
||||
"registrar": scan.get("registrar"),
|
||||
"expiry_date": scan.get("expiry_date"),
|
||||
}
|
||||
elif scan_type == "rdap":
|
||||
summary = {
|
||||
"handle": scan.get("handle"),
|
||||
"ldhName": scan.get("ldhName"),
|
||||
}
|
||||
elif scan_type == "dns":
|
||||
summary = {"records": scan.get("records")}
|
||||
elif scan_type == "ping":
|
||||
summary = {
|
||||
"loss_pct": scan.get("loss_pct"),
|
||||
"rtt_avg_ms": scan.get("rtt_avg_ms"),
|
||||
}
|
||||
elif scan_type == "traceroute":
|
||||
summary = {"hop_count": len(scan.get("hops") or [])}
|
||||
else: # nmap
|
||||
summary = {
|
||||
"open_ports": scan.get("open_ports"),
|
||||
"hosts_up": scan.get("hosts_up"),
|
||||
"profile": profile,
|
||||
}
|
||||
|
||||
# 4. Archiva en OSINT si procede.
|
||||
osint = None
|
||||
if save:
|
||||
osint = save_scan_to_osint(
|
||||
target,
|
||||
scan_type,
|
||||
scan.get("raw", ""),
|
||||
summary=summary,
|
||||
tool=scan_type,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"target": target,
|
||||
"scan_type": scan_type,
|
||||
"scan": scan,
|
||||
"osint": osint,
|
||||
}
|
||||
|
||||
|
||||
def _parse_cli(argv: list[str]) -> dict:
|
||||
"""Parsea los args de CLI: <target> [scan_type] [--confirm] [--allowlist a,b,c].
|
||||
|
||||
Mantiene compatibilidad: sin args usa el smoke por defecto; sin --confirm,
|
||||
confirm=False. Devuelve un dict de kwargs para recon_osint mas la allowlist.
|
||||
"""
|
||||
positional: list[str] = []
|
||||
confirm = False
|
||||
allowlist: list[str] | None = None
|
||||
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--confirm":
|
||||
confirm = True
|
||||
elif arg == "--allowlist":
|
||||
if i + 1 < len(argv):
|
||||
allowlist = [a.strip() for a in argv[i + 1].split(",") if a.strip()]
|
||||
i += 1
|
||||
elif arg.startswith("--allowlist="):
|
||||
raw = arg.split("=", 1)[1]
|
||||
allowlist = [a.strip() for a in raw.split(",") if a.strip()]
|
||||
else:
|
||||
positional.append(arg)
|
||||
i += 1
|
||||
|
||||
return {"positional": positional, "confirm": confirm, "allowlist": allowlist}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
parsed = _parse_cli(sys.argv[1:])
|
||||
positional = parsed["positional"]
|
||||
target = positional[0] if len(positional) >= 1 else "example.com"
|
||||
scan_type = positional[1] if len(positional) >= 2 else "whois"
|
||||
|
||||
# --confirm activa confirm directamente. --allowlist activa confirm cuando
|
||||
# el target esta autorizado en la lista (mismo criterio que nmap_scan).
|
||||
confirm = parsed["confirm"]
|
||||
allowlist = parsed["allowlist"]
|
||||
if not confirm and allowlist:
|
||||
t = target.strip()
|
||||
if any(t == a or t.endswith(a) for a in allowlist):
|
||||
confirm = True
|
||||
|
||||
try:
|
||||
result = recon_osint(
|
||||
target,
|
||||
scan_type,
|
||||
confirm=confirm,
|
||||
)
|
||||
print("status:", result.get("status"))
|
||||
# Para nmap rechazado por el guard, el dict trae stage=scan + scan.needs_confirm.
|
||||
scan = result.get("scan") or {}
|
||||
if scan.get("needs_confirm"):
|
||||
print("needs_confirm:", True)
|
||||
print("error:", scan.get("error"))
|
||||
osint = result.get("osint") or {}
|
||||
print("note_path:", osint.get("note_path"))
|
||||
print("registered:", osint.get("registered"))
|
||||
except Exception as exc: # smoke tolerante a red / servicios caidos
|
||||
print("smoke exception (tolerada):", repr(exc))
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests para el pipeline recon_osint — SIN red ni service real.
|
||||
|
||||
Las funciones de escaneo (whois_lookup, nmap_scan, ...) y el sink
|
||||
save_scan_to_osint se importan en el namespace del modulo del pipeline con
|
||||
``from cybersecurity import (...)``. Para aislarlos de la red/disco los
|
||||
parcheamos sobre los globals del propio modulo via importlib + monkeypatch.
|
||||
|
||||
Los tests usan kwargs minimos (target + scan_type + save) a proposito: la firma
|
||||
del pipeline puede ampliarse en paralelo (p.ej. con un parametro ``confirm``)
|
||||
sin que estos tests dejen de pasar.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
# Globals del modulo del pipeline (donde viven whois_lookup, save_scan_to_osint...).
|
||||
mod = importlib.import_module("pipelines.recon_osint")
|
||||
recon_osint = mod.recon_osint
|
||||
|
||||
|
||||
def test_golden_whois_save_true_invoca_scan_y_sink(monkeypatch):
|
||||
"""scan_type='whois', save=True: ejecuta whois_lookup y archiva con su raw."""
|
||||
fake_scan = {
|
||||
"status": "ok",
|
||||
"target": "example.com",
|
||||
"registrar": "Acme Registrar",
|
||||
"expiry_date": "2028-09-14T04:00:00Z",
|
||||
"raw": "Domain Name: EXAMPLE.COM\nRegistrar: Acme Registrar\n",
|
||||
}
|
||||
calls = {}
|
||||
|
||||
def fake_whois(target, **kwargs):
|
||||
calls["whois_target"] = target
|
||||
return fake_scan
|
||||
|
||||
def fake_save(target, scan_type, raw, **kwargs):
|
||||
calls["save"] = {
|
||||
"target": target,
|
||||
"scan_type": scan_type,
|
||||
"raw": raw,
|
||||
"kwargs": kwargs,
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"note_path": "dominios/example.com/recon/whois-20260614-1200.md",
|
||||
"registered": True,
|
||||
"scan_id": "scan-1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(mod, "whois_lookup", fake_whois)
|
||||
monkeypatch.setattr(mod, "save_scan_to_osint", fake_save)
|
||||
|
||||
result = recon_osint("example.com", scan_type="whois", save=True)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["scan_type"] == "whois"
|
||||
assert result["target"] == "example.com"
|
||||
# Devuelve el dict crudo del scan.
|
||||
assert result["scan"] == fake_scan
|
||||
# Devuelve los datos de archivado del sink.
|
||||
assert result["osint"]["registered"] is True
|
||||
assert result["osint"]["scan_id"] == "scan-1"
|
||||
|
||||
# whois_lookup recibio el target.
|
||||
assert calls["whois_target"] == "example.com"
|
||||
# save_scan_to_osint fue invocado con el raw del scan.
|
||||
assert "save" in calls
|
||||
assert calls["save"]["raw"] == fake_scan["raw"]
|
||||
assert calls["save"]["target"] == "example.com"
|
||||
assert calls["save"]["scan_type"] == "whois"
|
||||
|
||||
|
||||
def test_save_false_ejecuta_scan_sin_archivar(monkeypatch):
|
||||
"""save=False: corre el scan pero NO llama save_scan_to_osint."""
|
||||
fake_scan = {
|
||||
"status": "ok",
|
||||
"target": "example.com",
|
||||
"registrar": "Acme",
|
||||
"raw": "Domain Name: EXAMPLE.COM\n",
|
||||
}
|
||||
save_called = {"n": 0}
|
||||
|
||||
monkeypatch.setattr(mod, "whois_lookup", lambda target, **kw: fake_scan)
|
||||
|
||||
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
|
||||
save_called["n"] += 1
|
||||
return {"status": "ok"}
|
||||
|
||||
monkeypatch.setattr(mod, "save_scan_to_osint", fake_save)
|
||||
|
||||
result = recon_osint("example.com", scan_type="whois", save=False)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["scan"] == fake_scan
|
||||
# Sin archivado: osint es None y el sink nunca se invoco.
|
||||
assert result["osint"] is None
|
||||
assert save_called["n"] == 0
|
||||
|
||||
|
||||
def test_scan_type_invalido_error_sin_red(monkeypatch):
|
||||
"""scan_type desconocido: status error sin invocar ninguna funcion de scan/sink."""
|
||||
# Centinelas que petan si se invocan: el pipeline no debe tocar nada.
|
||||
def explode(*args, **kwargs): # pragma: no cover - no debe llamarse
|
||||
raise AssertionError("no debe ejecutarse scan ni sink con scan_type invalido")
|
||||
|
||||
monkeypatch.setattr(mod, "whois_lookup", explode)
|
||||
monkeypatch.setattr(mod, "nmap_scan", explode)
|
||||
monkeypatch.setattr(mod, "save_scan_to_osint", explode)
|
||||
|
||||
result = recon_osint("example.com", scan_type="bogus", save=True)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["stage"] == "validate"
|
||||
assert result["scan_type"] == "bogus"
|
||||
assert "valid" in result and isinstance(result["valid"], list)
|
||||
|
||||
|
||||
def test_scan_fallido_no_intenta_archivar(monkeypatch):
|
||||
"""Si el escaneo devuelve status error, no se llama al sink."""
|
||||
save_called = {"n": 0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"whois_lookup",
|
||||
lambda target, **kw: {"status": "error", "error": "timeout"},
|
||||
)
|
||||
|
||||
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
|
||||
save_called["n"] += 1
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(mod, "save_scan_to_osint", fake_save)
|
||||
|
||||
result = recon_osint("example.com", scan_type="whois", save=True)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["stage"] == "scan"
|
||||
assert save_called["n"] == 0
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: scan_port_services
|
||||
kind: pipeline
|
||||
lang: py
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def scan_port_services(host: str, ports: str | list[int] = 'common', timeout_s: float = 1.0, workers: int = 100, grab_banners: bool = True, banner_timeout_s: float = 3.0, save: bool = True) -> dict"
|
||||
description: "One-shot que escanea los servicios de los puertos de un host: hace un connect-scan TCP y, por cada puerto abierto, devuelve el servicio esperado por convencion IANA (identify_port_service) y el servicio/version REAL leido del banner en vivo (grab_service_banner). Reemplaza el patron scan_tcp_ports -> identify -> grab repetido (1 scan + 2*K por puerto abierto) por una sola llamada. Opcionalmente archiva la evidencia (tabla PORT/EXPECTED/ACTUAL/BANNER) en OSINT. No requiere nmap. Util para fingerprint de servicios, auditoria de superficie de ataque y reconocimiento de puertos de un host."
|
||||
tags: [recon, pipelines, cybersecurity, port-scan, service-detection, banner, sink]
|
||||
uses_functions:
|
||||
- scan_tcp_ports_py_cybersecurity
|
||||
- identify_port_service_py_cybersecurity
|
||||
- grab_service_banner_py_cybersecurity
|
||||
- save_scan_to_osint_py_cybersecurity
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: host
|
||||
desc: "Hostname o IP objetivo del escaneo (ej. 127.0.0.1, scanme.nmap.org, 10.0.0.5)."
|
||||
- name: ports
|
||||
desc: "Especificacion de puertos, se pasa tal cual a scan_tcp_ports. Acepta lista de ints ([22,80,443]), preset 'common' (~30 puertos comunes, default), rango '1-1024' o CSV '22,80,443' (con rangos mezclados '22,80,8000-8010')."
|
||||
- name: timeout_s
|
||||
desc: "Timeout por conexion TCP del connect-scan, en segundos. Default 1.0. Bajo en redes lentas puede marcar abiertos como filtered."
|
||||
- name: workers
|
||||
desc: "Numero de hilos concurrentes del escaneo de puertos. Default 100. Se acota al numero de puertos a escanear."
|
||||
- name: grab_banners
|
||||
desc: "Si True (default) llama grab_service_banner por cada puerto abierto para identificar el servicio/version real; si False solo usa identify_port_service (servicio esperado por convencion) sin tocar el servicio en vivo: mas rapido y mas sigiloso (sin segunda ronda de conexiones)."
|
||||
- name: banner_timeout_s
|
||||
desc: "Timeout del grab de banner por puerto, en segundos. Default 3.0. Solo aplica si grab_banners=True."
|
||||
- name: save
|
||||
desc: "Si True (default) archiva la evidencia en OSINT via save_scan_to_osint con scan_type='port_services'; si False solo ejecuta el escaneo y no toca el vault ni el service osint_db. Politica recon: todo scan se archiva. Si el sink falla, el resultado degrada sin romper (saved.status='error')."
|
||||
output: "dict con status ('ok'|'error'), host, ip (resuelta), open_ports (lista de ints), services (lista de dicts con port, expected_service, expected_desc, actual_service, product, version, banner, match), saved (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False) y raw (tabla legible PORT/EXPECTED/ACTUAL/BANNER). Si el escaneo de puertos falla (host no resuelve, spec invalida) -> {status:error, stage:scan, scan:<dict>}. Cuando grab_banners=False, actual_service/product/version/banner quedan None/'' y match=None. Nunca lanza."
|
||||
tested: true
|
||||
tests: ["test_golden_scan_localhost_con_banner_real", "test_grab_banners_false_solo_servicio_esperado", "test_scan_fallido_propaga_error_sin_red", "test_save_false_no_archiva_osint"]
|
||||
test_file_path: "python/functions/pipelines/scan_port_services_test.py"
|
||||
file_path: "python/functions/pipelines/scan_port_services.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from pipelines.scan_port_services import scan_port_services
|
||||
|
||||
# Escaneo + fingerprint de servicios de un host, archivado en OSINT (1 paso).
|
||||
r = scan_port_services("127.0.0.1", ports="common")
|
||||
print(r["status"]) # "ok"
|
||||
print(r["open_ports"]) # [22, 5432, 6379]
|
||||
for s in r["services"]:
|
||||
print(s["port"], s["expected_service"], "->", s["actual_service"], s["version"])
|
||||
# 22 ssh -> ssh 9.6p1
|
||||
print(r["saved"]["note_path"]) # ruta de la nota creada en el vault osint
|
||||
```
|
||||
|
||||
```python
|
||||
from pipelines.scan_port_services import scan_port_services
|
||||
|
||||
# Solo servicio esperado por convencion (sin tocar el servicio en vivo), sin archivar.
|
||||
r = scan_port_services("10.0.0.5", ports=[22, 80, 443, 3306], grab_banners=False, save=False)
|
||||
print(r["raw"]) # tabla PORT/EXPECTED/ACTUAL/BANNER (ACTUAL vacio)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Por CLI: escanea los puertos comunes de un host.
|
||||
./fn run scan_port_services 127.0.0.1 common
|
||||
# Flags: --no-banners (solo servicio esperado), --no-save (no archiva OSINT).
|
||||
./fn run scan_port_services scanme.nmap.org 22,80,443 --no-save
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras en UN solo paso saber que puertos estan abiertos en un host Y
|
||||
que servicio/version corre en cada uno, sin nmap. Reemplaza el patron repetido
|
||||
`scan_tcp_ports` -> `identify_port_service` -> `grab_service_banner` (una ronda
|
||||
de scan + dos llamadas por cada puerto abierto). Tipico para: fingerprint de
|
||||
servicios de un objetivo, auditoria de superficie de ataque, validar que un
|
||||
puerto abierto corre el servicio esperado (campo `match`), o reconocimiento
|
||||
inicial de un host autorizado.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Ruidoso/detectable**: es un connect-scan (handshake TCP completo) seguido,
|
||||
si `grab_banners=True`, de una segunda conexion por puerto para leer el
|
||||
banner. Deja rastro en logs del objetivo. Usa `grab_banners=False` para un
|
||||
paso menos invasivo (sin segunda ronda).
|
||||
- **Servicios sobre TLS no dan banner plano**: puertos como 443/993/995/8443
|
||||
hablan TLS, no emiten un banner texto al conectar, asi que `actual_service`
|
||||
quedara `"unknown"` ahi (no hay handshake TLS en `grab_service_banner`). El
|
||||
`expected_service` (https/imaps/...) si lo identifica por convencion.
|
||||
- **match es heuristico**: `match=True` solo cuando expected y actual son ambos
|
||||
concretos (no "unknown") y coinciden. Un `match=False` puede significar
|
||||
"no coinciden" o "no se pudo determinar el real"; mira `actual_service`.
|
||||
- **save=True escribe en el vault OSINT** (`~/Obsidian/osint`) y hace POST al
|
||||
service `osint_db` (`http://127.0.0.1:8771`). Si el service esta caido,
|
||||
`save_scan_to_osint` degrada a solo-nota (`saved.registered=False` con
|
||||
`register_warning`); el pipeline no falla por eso.
|
||||
- **Autorizacion legal**: escanear puertos y leer banners de hosts ajenos sin
|
||||
permiso puede ser ilegal. Solo objetivos propios o con autorizacion explicita.
|
||||
- **Pipeline impuro**: hace red (scan + banners) y FS/HTTP (vault + service).
|
||||
No es determinista entre ejecuciones.
|
||||
- Si el escaneo de puertos falla (`status != "ok"`: host no resuelve, spec
|
||||
invalida), el pipeline devuelve `{"status":"error","stage":"scan",...}` y
|
||||
**no** intenta identificar servicios ni archivar nada.
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline que compone 4 funciones atomicas del dominio `cybersecurity`. No
|
||||
reimplementa logica de escaneo, identificacion ni persistencia: solo orquesta
|
||||
`scan_tcp_ports` (puertos abiertos) + `identify_port_service` (servicio esperado,
|
||||
puro) + `grab_service_banner` (servicio real, por puerto abierto) y delega el
|
||||
guardado en `save_scan_to_osint`. El grab de banners es secuencial por KISS (los
|
||||
puertos abiertos suelen ser pocos y cada grab ya tiene timeout acotado). Nunca
|
||||
lanza excepciones: todo fallo se refleja en la clave `status` del dict devuelto.
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Pipeline scan_port_services.
|
||||
|
||||
One-shot que materializa el flujo "escanear los servicios de los diferentes
|
||||
puertos de un host": escanea puertos TCP y, para cada puerto ABIERTO, obtiene
|
||||
(a) el servicio que se espera por convencion IANA en ese puerto y (b) el
|
||||
servicio/version REAL leyendo el banner del servicio en vivo. Opcionalmente
|
||||
archiva la evidencia en OSINT.
|
||||
|
||||
Convierte el patron de N llamadas (1 scan + 2*K por cada puerto abierto:
|
||||
identify_port_service + grab_service_banner) en una sola invocacion. Compone
|
||||
funciones del registry del dominio cybersecurity; no reescribe ninguna logica
|
||||
de escaneo, identificacion ni persistencia.
|
||||
|
||||
Funciones del registry compuestas (importadas, no reimplementadas):
|
||||
scan_tcp_ports, identify_port_service, grab_service_banner,
|
||||
save_scan_to_osint
|
||||
"""
|
||||
|
||||
from cybersecurity import (
|
||||
scan_tcp_ports,
|
||||
identify_port_service,
|
||||
grab_service_banner,
|
||||
save_scan_to_osint,
|
||||
)
|
||||
|
||||
|
||||
def _build_raw(host: str, ip: str, services: list[dict]) -> str:
|
||||
"""Construye una tabla legible PORT / EXPECTED / ACTUAL / BANNER para evidencia.
|
||||
|
||||
Args:
|
||||
host: host objetivo del escaneo.
|
||||
ip: IP resuelta del host.
|
||||
services: lista de dicts de servicio (ver scan_port_services).
|
||||
|
||||
Returns:
|
||||
Bloque de texto multi-linea con cabecera y una fila por puerto abierto.
|
||||
"""
|
||||
header = f"# scan_port_services {host} ({ip})"
|
||||
cols = f"{'PORT':<8}{'EXPECTED':<16}{'ACTUAL':<16}BANNER"
|
||||
lines = [header, "", cols]
|
||||
for s in services:
|
||||
port = str(s.get("port", ""))
|
||||
expected = str(s.get("expected_service") or "")
|
||||
actual = str(s.get("actual_service") or "")
|
||||
banner = (s.get("banner") or "").replace("\r", " ").replace("\n", " ").strip()
|
||||
if len(banner) > 80:
|
||||
banner = banner[:77] + "..."
|
||||
lines.append(f"{port:<8}{expected:<16}{actual:<16}{banner}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def scan_port_services(
|
||||
host: str,
|
||||
ports: "str | list[int]" = "common",
|
||||
timeout_s: float = 1.0,
|
||||
workers: int = 100,
|
||||
grab_banners: bool = True,
|
||||
banner_timeout_s: float = 3.0,
|
||||
save: bool = True,
|
||||
) -> dict:
|
||||
"""Escanea puertos de un host e identifica el servicio (esperado y real) de cada uno.
|
||||
|
||||
Compone, en un solo paso:
|
||||
1. ``scan_tcp_ports(host, ports, ...)`` para hallar los puertos abiertos.
|
||||
2. Por cada puerto abierto, ``identify_port_service(port)`` (servicio que
|
||||
la convencion IANA espera ahi) y, si ``grab_banners`` es True,
|
||||
``grab_service_banner(host, port, ...)`` (servicio/version REAL leido
|
||||
del banner en vivo).
|
||||
3. Si ``save`` es True, archiva una tabla de evidencia en OSINT via
|
||||
``save_scan_to_osint`` con ``scan_type="port_services"``.
|
||||
|
||||
Nunca lanza excepciones: cualquier fallo se refleja en la clave ``status``
|
||||
del dict devuelto.
|
||||
|
||||
Args:
|
||||
host: hostname o IP objetivo (ej. "127.0.0.1", "scanme.nmap.org").
|
||||
ports: especificacion de puertos, se pasa tal cual a scan_tcp_ports.
|
||||
Acepta lista de ints ([22, 80, 443]), string preset "common"
|
||||
(~30 puertos comunes), string rango "1-1024" o string CSV
|
||||
"22,80,443" (con rangos mezclados).
|
||||
timeout_s: timeout por conexion TCP del escaneo de puertos, en segundos.
|
||||
Default 1.0.
|
||||
workers: numero de hilos concurrentes del escaneo. Default 100.
|
||||
grab_banners: si True (default) llama grab_service_banner por cada
|
||||
puerto abierto para identificar el servicio/version real; si False
|
||||
solo usa identify_port_service (sin tocar el servicio en vivo -> mas
|
||||
rapido y mas sigiloso, sin segunda ronda de conexiones).
|
||||
banner_timeout_s: timeout del grab de banner por puerto, en segundos.
|
||||
Default 3.0. Solo aplica si grab_banners=True.
|
||||
save: si True (default) archiva la evidencia en OSINT via
|
||||
save_scan_to_osint (scan_type="port_services"); si False solo
|
||||
ejecuta el escaneo y no toca el vault ni el service. Si el sink
|
||||
falla, el resultado degrada sin romper (osint.status="error").
|
||||
|
||||
Returns:
|
||||
dict de estado. Nunca lanza.
|
||||
ok::
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"host": <host>,
|
||||
"ip": <ip resuelta>,
|
||||
"open_ports": [22, 5432, 6379],
|
||||
"services": [
|
||||
{
|
||||
"port": 22,
|
||||
"expected_service": "ssh",
|
||||
"expected_desc": "Secure Shell",
|
||||
"actual_service": "ssh", # None si grab_banners=False
|
||||
"product": "OpenSSH", # "" si no se grabo banner
|
||||
"version": "9.6p1", # "" si no se grabo banner
|
||||
"banner": "SSH-2.0-OpenSSH_9.6p1 ...", # "" si no se grabo
|
||||
"match": True, # expected==actual; None si no se grabo
|
||||
},
|
||||
...
|
||||
],
|
||||
"saved": <dict de save_scan_to_osint> | None,
|
||||
"raw": "# scan_port_services ...\nPORT EXPECTED ...",
|
||||
}
|
||||
|
||||
error (el escaneo de puertos fallo: host no resuelve, spec invalida)::
|
||||
|
||||
{"status": "error", "stage": "scan", "scan": <dict del scan>}
|
||||
"""
|
||||
# 1. Escaneo de puertos. Si falla, propagamos sin continuar.
|
||||
scan = scan_tcp_ports(host, ports=ports, timeout_s=timeout_s, workers=workers)
|
||||
if scan.get("status") != "ok":
|
||||
return {"status": "error", "stage": "scan", "scan": scan}
|
||||
|
||||
ip = scan.get("ip", "")
|
||||
open_ports = list(scan.get("open") or [])
|
||||
|
||||
# 2. Por cada puerto abierto: servicio esperado (puro) + servicio real (banner).
|
||||
# Secuencial a proposito (KISS): los puertos abiertos suelen ser pocos y
|
||||
# cada grab ya tiene su propio timeout acotado.
|
||||
services: list[dict] = []
|
||||
for port in open_ports:
|
||||
expected = identify_port_service(port, proto="tcp")
|
||||
entry = {
|
||||
"port": port,
|
||||
"expected_service": expected.get("service"),
|
||||
"expected_desc": expected.get("description"),
|
||||
"actual_service": None,
|
||||
"product": "",
|
||||
"version": "",
|
||||
"banner": "",
|
||||
"match": None,
|
||||
}
|
||||
|
||||
if grab_banners:
|
||||
banner_res = grab_service_banner(
|
||||
host, port, timeout_s=banner_timeout_s, send_probe=True
|
||||
)
|
||||
if banner_res.get("status") == "ok":
|
||||
actual = banner_res.get("service")
|
||||
entry["actual_service"] = actual
|
||||
entry["product"] = banner_res.get("product", "")
|
||||
entry["version"] = banner_res.get("version", "")
|
||||
entry["banner"] = banner_res.get("banner", "")
|
||||
# match solo es significativo si ambos servicios son concretos.
|
||||
exp = expected.get("service")
|
||||
entry["match"] = (
|
||||
exp == actual
|
||||
if actual and actual != "unknown" and exp and exp != "unknown"
|
||||
else False
|
||||
)
|
||||
else:
|
||||
# El grab fallo (timeout/refused): dejamos actual como "unknown"
|
||||
# para distinguirlo de "no se intento" (None con grab_banners=False).
|
||||
entry["actual_service"] = "unknown"
|
||||
entry["match"] = False
|
||||
|
||||
services.append(entry)
|
||||
|
||||
raw = _build_raw(host, ip, services)
|
||||
|
||||
# 3. Archiva la evidencia en OSINT si procede (degrada sin romper).
|
||||
saved = None
|
||||
if save:
|
||||
summary = {
|
||||
"open_ports": open_ports,
|
||||
"services_detected": [
|
||||
f"{s['port']}:{s.get('actual_service') or s.get('expected_service')}"
|
||||
for s in services
|
||||
],
|
||||
}
|
||||
saved = save_scan_to_osint(
|
||||
host,
|
||||
"port_services",
|
||||
raw,
|
||||
summary=summary,
|
||||
tool="scan_port_services",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"host": host,
|
||||
"ip": ip,
|
||||
"open_ports": open_ports,
|
||||
"services": services,
|
||||
"saved": saved,
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _parse_cli(argv: list[str]) -> dict:
|
||||
"""Parsea los args de CLI: <host> [ports] [--no-banners] [--no-save].
|
||||
|
||||
Devuelve un dict de kwargs para scan_port_services.
|
||||
"""
|
||||
positional: list[str] = []
|
||||
grab_banners = True
|
||||
save = True
|
||||
|
||||
for arg in argv:
|
||||
if arg == "--no-banners":
|
||||
grab_banners = False
|
||||
elif arg == "--no-save":
|
||||
save = False
|
||||
else:
|
||||
positional.append(arg)
|
||||
|
||||
return {"positional": positional, "grab_banners": grab_banners, "save": save}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
parsed = _parse_cli(sys.argv[1:])
|
||||
positional = parsed["positional"]
|
||||
host = positional[0] if len(positional) >= 1 else "127.0.0.1"
|
||||
ports = positional[1] if len(positional) >= 2 else "common"
|
||||
|
||||
try:
|
||||
result = scan_port_services(
|
||||
host,
|
||||
ports=ports,
|
||||
grab_banners=parsed["grab_banners"],
|
||||
save=parsed["save"],
|
||||
)
|
||||
print("status:", result.get("status"))
|
||||
if result.get("status") == "ok":
|
||||
print(f"host: {result['host']} ({result['ip']})")
|
||||
print("open_ports:", result["open_ports"])
|
||||
print("--- services ---")
|
||||
print(result["raw"])
|
||||
saved = result.get("saved") or {}
|
||||
if saved:
|
||||
print("note_path:", saved.get("note_path"))
|
||||
print("registered:", saved.get("registered"))
|
||||
else:
|
||||
print("error:", result.get("scan", {}).get("error"))
|
||||
except Exception as exc: # noqa: BLE001 - smoke nunca debe romper
|
||||
print("smoke exception (tolerada):", repr(exc))
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Tests para el pipeline scan_port_services — SIN red externa ni service real.
|
||||
|
||||
El golden levanta un servidor TCP local efimero en 127.0.0.1 que emite un
|
||||
banner SSH falso al conectar; el pipeline compone scan_tcp_ports +
|
||||
identify_port_service + grab_service_banner contra ese puerto 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 importlib
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
# Globals del modulo del pipeline (donde viven scan_tcp_ports, save_scan_to_osint...).
|
||||
mod = importlib.import_module("pipelines.scan_port_services")
|
||||
scan_port_services = mod.scan_port_services
|
||||
|
||||
|
||||
def _start_banner_server(banner: bytes) -> tuple[socket.socket, int, threading.Thread]:
|
||||
"""Levanta un servidor TCP efimero en 127.0.0.1 que emite `banner` por conexion.
|
||||
|
||||
Sirve en bucle hasta que el socket se cierra: el pipeline hace DOS conexiones
|
||||
al puerto (el connect-scan de scan_tcp_ports + el grab de grab_service_banner),
|
||||
asi que el servidor debe aceptar varias, no solo una.
|
||||
|
||||
Returns:
|
||||
(server_socket, port, thread). El caller debe cerrar el socket al final.
|
||||
"""
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", 0)) # puerto efimero asignado por el SO
|
||||
srv.listen(8)
|
||||
port = srv.getsockname()[1]
|
||||
|
||||
def serve():
|
||||
# Bucle de accept: cada conexion recibe el banner y se cierra. El bucle
|
||||
# termina cuando el caller cierra `srv` (accept lanza OSError). Tolerante
|
||||
# a fallos: nunca rompe el test.
|
||||
srv.settimeout(5.0)
|
||||
while True:
|
||||
try:
|
||||
conn, _ = srv.accept()
|
||||
except OSError:
|
||||
break # socket cerrado por el caller -> fin del servidor
|
||||
try:
|
||||
conn.sendall(banner)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=serve, daemon=True)
|
||||
t.start()
|
||||
return srv, port, t
|
||||
|
||||
|
||||
# --- 1. Golden: scan + identify + grab de banner real en localhost -----------
|
||||
|
||||
def test_golden_scan_localhost_con_banner_real():
|
||||
"""grab_banners=True: detecta el puerto abierto y lee el banner SSH falso."""
|
||||
srv, port, thread = _start_banner_server(b"SSH-2.0-Test_1.0\r\n")
|
||||
try:
|
||||
result = scan_port_services(
|
||||
"127.0.0.1",
|
||||
ports=[port],
|
||||
timeout_s=1.0,
|
||||
grab_banners=True,
|
||||
banner_timeout_s=2.0,
|
||||
save=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["ip"] == "127.0.0.1"
|
||||
# El puerto efimero aparece como abierto.
|
||||
assert port in result["open_ports"]
|
||||
# No se archivo en OSINT.
|
||||
assert result["saved"] is None
|
||||
|
||||
# services trae una entrada para el puerto, con servicio esperado y real.
|
||||
assert len(result["services"]) == 1
|
||||
svc = result["services"][0]
|
||||
assert svc["port"] == port
|
||||
# expected_service viene de identify_port_service (puerto efimero alto ->
|
||||
# no esta en la tabla IANA, asi que "unknown"; la clave debe existir).
|
||||
assert "expected_service" in svc
|
||||
# actual_service viene del banner SSH falso emitido por el servidor local.
|
||||
assert svc["actual_service"] == "ssh"
|
||||
assert "SSH-2.0-Test" in svc["banner"]
|
||||
# match es bool cuando se grabo banner (no None).
|
||||
assert svc["match"] in (True, False)
|
||||
|
||||
# raw es una tabla legible con el puerto.
|
||||
assert isinstance(result["raw"], str)
|
||||
assert str(port) in result["raw"]
|
||||
assert "EXPECTED" in result["raw"]
|
||||
finally:
|
||||
srv.close()
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
|
||||
# --- 2. grab_banners=False: solo servicio esperado, sin tocar el servicio ----
|
||||
|
||||
def test_grab_banners_false_solo_servicio_esperado():
|
||||
"""grab_banners=False: identifica el esperado pero no abre segunda conexion."""
|
||||
srv, port, thread = _start_banner_server(b"SSH-2.0-NoDebeLeerse\r\n")
|
||||
try:
|
||||
result = scan_port_services(
|
||||
"127.0.0.1",
|
||||
ports=[port],
|
||||
timeout_s=1.0,
|
||||
grab_banners=False,
|
||||
save=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert port in result["open_ports"]
|
||||
svc = result["services"][0]
|
||||
# Sin grab: actual_service/version/banner quedan None/'' y match=None.
|
||||
assert svc["actual_service"] is None
|
||||
assert svc["banner"] == ""
|
||||
assert svc["version"] == ""
|
||||
assert svc["match"] is None
|
||||
# expected_service si esta presente (de identify_port_service).
|
||||
assert "expected_service" in svc
|
||||
finally:
|
||||
srv.close()
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
|
||||
# --- 3. Error path: el escaneo de puertos falla -> error sin red -------------
|
||||
|
||||
def test_scan_fallido_propaga_error_sin_red():
|
||||
"""Host que no resuelve: scan_tcp_ports 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 scan fallido no debe invocarse.
|
||||
import contextlib
|
||||
|
||||
original_save = mod.save_scan_to_osint
|
||||
mod.save_scan_to_osint = fake_save
|
||||
try:
|
||||
result = scan_port_services(
|
||||
"nohost.invalid.tld.example",
|
||||
ports=[80],
|
||||
timeout_s=0.5,
|
||||
save=True,
|
||||
)
|
||||
finally:
|
||||
mod.save_scan_to_osint = original_save
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["stage"] == "scan"
|
||||
assert result["scan"]["status"] == "error"
|
||||
# No se intento archivar nada.
|
||||
assert save_called["n"] == 0
|
||||
# Silencia un linter por contextlib import no usado fuera.
|
||||
_ = contextlib
|
||||
|
||||
|
||||
# --- 4. save=False: corre el scan completo pero NO archiva en OSINT -----------
|
||||
|
||||
def test_save_false_no_archiva_osint():
|
||||
"""save=False sobre un puerto abierto real: services poblado, saved=None."""
|
||||
save_called = {"n": 0}
|
||||
|
||||
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
|
||||
save_called["n"] += 1
|
||||
return {"status": "ok"}
|
||||
|
||||
srv, port, thread = _start_banner_server(b"SSH-2.0-Test_1.0\r\n")
|
||||
original_save = mod.save_scan_to_osint
|
||||
mod.save_scan_to_osint = fake_save
|
||||
try:
|
||||
result = scan_port_services(
|
||||
"127.0.0.1",
|
||||
ports=[port],
|
||||
timeout_s=1.0,
|
||||
grab_banners=True,
|
||||
banner_timeout_s=2.0,
|
||||
save=False,
|
||||
)
|
||||
finally:
|
||||
mod.save_scan_to_osint = original_save
|
||||
srv.close()
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert port in result["open_ports"]
|
||||
assert result["saved"] is None
|
||||
# El sink nunca se invoco con save=False.
|
||||
assert save_called["n"] == 0
|
||||
Reference in New Issue
Block a user