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,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
+115
View File
@@ -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).
+228
View File
@@ -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