diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 37a5320d..bff8c00b 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -53,6 +53,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use | | [obsidian](obsidian.md) | 16 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve, render tabla Markdown + bloques sentinel gestionados. Sin app GUI | | [duckdb](duckdb.md) | 5 | Operar bases DuckDB: open (Go), query read-only segura (Python, tipos JSON-safe), CSV->Parquet, dedup por hash, carga OHLCV. Base del patron BD-fuente-de-verdad + Obsidian-vista (app osint_db) | +| [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados | | [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks | | [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments | diff --git a/docs/capabilities/recon.md b/docs/capabilities/recon.md new file mode 100644 index 00000000..602f9445 --- /dev/null +++ b/docs/capabilities/recon.md @@ -0,0 +1,179 @@ +# Capability: recon + +Reconocimiento de red para OSINT desde el registry: lookups de registro (WHOIS/RDAP), DNS, sondeo de disponibilidad y ruta (ping/traceroute), escaneo de puertos y servicios, y fingerprint de la tecnologia web de un sitio (estilo Wappalyzer). El escaneo de puertos tiene dos caminos: el wrapper pesado de `nmap` (perfiles, scripts NSE, versiones), y un **camino nativo en Python puro** (`scan_tcp_ports` + `grab_service_banner` + `identify_port_service`, solo stdlib, sin nmap ni sudo) para escaneo rapido y portable. El fingerprint web sigue el mismo patron pura/impura: `fetch_http_fingerprint` recoge las señales (headers, html, cookies) y `detect_web_tech` (pura) matchea firmas para identificar servidor, CMS, frameworks JS, analytics y CDN. La mayoria de funciones son Python impuras, wrappean CLIs del sistema (`whois`, `rdap`, `dig`, `ping`, `traceroute`, `nmap`) o usan sockets/urllib stdlib, y devuelven siempre un dict `{status: ok|error}` sin lanzar excepciones. El grupo cierra el bucle con un **sink comun** que archiva cada escaneo en el ecosistema OSINT (nota Obsidian + registro DuckDB) y pipelines one-shot que escanean y guardan en una sola llamada. + +Comparte tag y dominio (`cybersecurity`) con el grupo `osint-passive` (recoleccion no intrusiva desde fuentes publicas), del que reutiliza primitivas. La regla de operacion es la misma del project `osint`: **todo escaneo se archiva en OSINT**. + +## Funciones + +| ID | Firma | Que hace | +|---|---|---| +| `whois_lookup_py_cybersecurity` | `whois_lookup(target, timeout_s=30) -> dict` | Lookup WHOIS via el CLI `whois`. Captura el `raw` completo y parsea best-effort registrar, registrant_country, creation_date, expiry_date, updated_date, name_servers. Acepta dominio o IP. | +| `rdap_lookup_py_cybersecurity` | `rdap_lookup(target, timeout_s=30) -> dict` | Lookup RDAP (reemplazo JSON moderno de WHOIS) via el CLI openrdap `rdap`. Devuelve `data` (dict JSON), `handle`, `ldhName` y el `raw`. Acepta dominio, IP o ASN (`AS15169`). | +| `dns_records_py_cybersecurity` | `dns_records(domain, record_types=None, timeout_s=20) -> dict` | Registros DNS via `dig +short` (default A, AAAA, MX, NS, SOA, TXT, CNAME). Devuelve `records` (dict por tipo) y `raw` legible por bloque para el vault. | +| `ping_host_py_cybersecurity` | `ping_host(host, count=4, timeout_s=30) -> dict` | Sondeo ICMP via `ping`. Devuelve `loss_pct`, `rtt_avg_ms` (y min/max), `packets_sent`/`recv`, `raw`. Host filtrado = `status:ok` con `loss_pct=100`, no error. | +| `traceroute_host_py_cybersecurity` | `traceroute_host(host, max_hops=30, timeout_s=60) -> dict` | Traza la ruta via `traceroute`. Devuelve `hops` (lista de `{hop, hosts:[{name, ip, rtt_ms}]}`) y `raw`. Hops filtrados (`* * *`) = `hosts: []`. | +| `nmap_scan_py_cybersecurity` | `nmap_scan(target, profile="quick", ports=None, extra_args=None, out_dir=None, timeout_s=1800) -> dict` | Escaneo de puertos/servicios via `nmap` por perfiles (salida XML parseada). Devuelve `open_ports`, `hosts_up`, `xml_path`, `raw`, `elapsed_s`. Funcion estrella del grupo. | +| `scan_tcp_ports_py_cybersecurity` | `scan_tcp_ports(host, ports="common", timeout_s=1.0, workers=100) -> dict` | **Connect-scan TCP nativo (stdlib, sin nmap ni sudo).** Escanea puertos en paralelo con threads y clasifica cada uno en open/closed/filtered. `ports` acepta lista, preset `"common"`, rango `"1-1024"` o CSV. Devuelve `open` (lista de ints), `ip`, `raw`. NO detecta version de servicio. | +| `grab_service_banner_py_cybersecurity` | `grab_service_banner(host, port, timeout_s=3.0, send_probe=True) -> dict` | **Banner grab nativo (stdlib, sin nmap -sV).** Abre socket TCP, lee el banner e identifica el servicio real (ssh, http, ftp, smtp, mysql, redis, pop3, imap, telnet...) extrayendo `product` y `version` best-effort. Dice QUE habla detras de un puerto abierto. TLS/HTTPS no da banner plano. | +| `identify_port_service_py_cybersecurity` | `identify_port_service(port, proto="tcp") -> dict` | **Pure.** Mapea un puerto a su servicio IANA well-known esperado por convencion (`{service, description, known}`) desde una tabla embebida (~120 puertos). No sondea en vivo: dice que se ESPERA, no que hay. | +| `save_scan_to_osint_py_cybersecurity` | `save_scan_to_osint(target, scan_type, raw, summary=None, vault_dir="~/Obsidian/osint", service_url="http://127.0.0.1:8771", tool=None) -> dict` | **Sink OSINT.** Archiva un scan: nota Markdown tipada en el vault (capa critica) + POST a `osint_db` para registro DuckDB (best-effort). Devuelve `note_path`, `registered`, `scan_id`. | +| `recon_osint_py_pipelines` | `recon_osint(target, scan_type="whois", save=True, profile="quick", ...) -> dict` | **Pipeline one-shot.** Ejecuta un scan del tipo pedido y lo archiva en OSINT en una sola llamada (compone la funcion de scan + `save_scan_to_osint`). El camino canonico para recon + archivado. | +| `scan_port_services_py_pipelines` | `scan_port_services(host, ports="common", timeout_s=1.0, workers=100, grab_banners=True, banner_timeout_s=3.0, save=True) -> dict` | **Pipeline one-shot nativo.** Escanea puertos y, por cada abierto, devuelve servicio esperado (IANA) + servicio/version real del banner. Compone `scan_tcp_ports` + `identify_port_service` + `grab_service_banner` (+ sink OSINT). Reemplaza el patron scan→identify→grab sin nmap. | +| `fetch_http_fingerprint_py_cybersecurity` | `fetch_http_fingerprint(url, timeout_s=15.0, verify_tls=True, max_html_bytes=500000, user_agent=None) -> dict` | **Fetch de señales web (stdlib).** GET con UA de navegador, sigue redirects, descomprime gzip. Devuelve `headers` (lowercase), `cookies` (solo NOMBRES, sin valores), `html`, `title`, `server`, `status_code`, `final_url`, `raw`. Capa impura del fingerprint web. | +| `detect_web_tech_py_cybersecurity` | `detect_web_tech(headers, html="", cookies=None, final_url="") -> dict` | **Pure. Detector de tecnologia web estilo Wappalyzer.** Matchea ~50 firmas embebidas (regex) contra headers/html/cookies → `technologies[{name, category, version, confidence, evidence}]`, `by_category`, `count`. Cubre server, lenguaje, CMS, frameworks JS, librerias, analytics, CDN, e-commerce, WAF. | +| `fingerprint_web_stack_py_pipelines` | `fingerprint_web_stack(url, timeout_s=15.0, verify_tls=True, max_html_bytes=500000, save=True) -> dict` | **Pipeline one-shot = Wappalyzer del registry.** url → tecnologias detectadas. Compone `fetch_http_fingerprint` + `detect_web_tech` (+ sink OSINT). El camino canonico para fingerprint web. | + +### OSINT pasivo relacionado + +Estas funciones llevan tambien el tag `recon` (y `osint-passive`): recoleccion no intrusiva desde fuentes publicas, sin tocar al objetivo. Utiles antes o junto al escaneo de red. Pagina madre completa: `docs/capabilities/osint-passive.md`. + +| ID | Firma | Que hace | +|---|---|---| +| `build_search_dorks_py_cybersecurity` | `build_search_dorks(target, tipo="persona", extra_domains=None) -> list` | **Pure.** Genera dorks de buscador (frase exacta, `site:`, `filetype:`, leaks/pastebin) segun el tipo de target. Sin red. | +| `enum_subdomains_crtsh_py_cybersecurity` | `enum_subdomains_crtsh(dominio, timeout_s=20.0) -> list` | Enumera subdominios desde Certificate Transparency (crt.sh). Dedup, ordenado, sin wildcards. | +| `enumerate_username_sites_py_cybersecurity` | `enumerate_username_sites(username, timeout_s=8.0, sites=None) -> list` | Comprueba si un username existe en ~12 sitios publicos (estilo sherlock ligero) por codigo HTTP. | +| `guess_email_formats_py_cybersecurity` | `guess_email_formats(nombre, apellidos, dominio) -> list` | **Pure.** Genera candidatos de email comunes (nombre.apellido, inicial+apellido, ...). Sin red. | +| `enrich_org_passive_py_cybersecurity` | `enrich_org_passive(dominio) -> dict` | Orquestador: perfil pasivo de una organizacion componiendo whois + dns + subdominios crt.sh. | + +## Ejemplo canonico end-to-end + +**1. One-shot (preferido): escanear y archivar en una llamada.** El pipeline corre el scan y lo guarda en OSINT (nota + registro DuckDB) por ti. + +```bash +cd /home/enmanuel/fn_registry +./fn run recon_osint ejemplo.com whois +``` + +Equivalente desde Python (cuando necesitas el dict de resultado): + +```bash +python/.venv/bin/python3 - <<'PYEOF' +import sys +sys.path.insert(0, "python/functions") +from pipelines.recon_osint import recon_osint + +res = recon_osint("ejemplo.com", scan_type="whois", save=True) +print(res["status"], res.get("note_path"), res.get("registered")) +PYEOF +``` + +**2. Manual atomico + sink.** Cuando quieres controlar el scan (perfil, puertos, summary propio) y guardarlo aparte. La funcion de scan se importa, no se reescribe. + +```bash +cd /home/enmanuel/fn_registry +python/.venv/bin/python3 - <<'PYEOF' +import sys +sys.path.insert(0, "python/functions") +from cybersecurity import dns_records +from cybersecurity.save_scan_to_osint import save_scan_to_osint + +scan = dns_records("ejemplo.com") # 1. escanear +if scan["status"] == "ok": + saved = save_scan_to_osint( # 2. archivar en OSINT + "ejemplo.com", + "dns", + scan["raw"], + summary={"A": scan["records"].get("A"), "MX": scan["records"].get("MX")}, + tool="dig", + ) + print(saved["note_path"], "registered:", saved["registered"]) +PYEOF +``` + +**3. nmap largo en segundo plano.** Los perfiles pesados tardan de minutos a horas: lanzalos en background con `out_dir` (conserva el XML) y `timeout_s` alto, y archiva al terminar. + +```bash +cd /home/enmanuel/fn_registry +# El pipeline one-shot tambien sirve para nmap; lanzar en background por la duracion: +nohup ./fn run recon_osint scanme.nmap.org nmap --profile full-tcp --timeout-s 7200 \ + > /tmp/recon-fulltcp.log 2>&1 & +``` + +> `scanme.nmap.org` es el host oficial de pruebas de nmap (legal escanear). Cualquier otro objetivo de terceros exige autorizacion. + +**4. Escaneo nativo de servicios de puertos (sin nmap), one-shot.** Cuando no quieres depender de `nmap`/sudo o buscas un barrido rapido y portable: el pipeline `scan_port_services` escanea los puertos y, por cada abierto, dice el servicio esperado por convencion (IANA) y el servicio/version real leido del banner. + +```bash +cd /home/enmanuel/fn_registry +python/.venv/bin/python3 - <<'PYEOF' +import sys +sys.path.insert(0, "python/functions") +from pipelines.scan_port_services import scan_port_services + +res = scan_port_services("scanme.nmap.org", ports="common", save=True) +print(res["status"], "abiertos:", res.get("open_ports")) +for s in res.get("services", []): + print(f" {s['port']}: esperado={s['expected_service']} real={s.get('actual_service')} version={s.get('version')}") +PYEOF +``` + +Las primitivas tambien sirven sueltas: `scan_tcp_ports(host, ports)` para solo el estado de los puertos, `grab_service_banner(host, port)` para identificar un servicio concreto, e `identify_port_service(port)` (pura) para el servicio esperado por convencion. + +**5. Fingerprint de tecnologia web (Wappalyzer del registry), one-shot.** Identifica el stack de un sitio — servidor, lenguaje, CMS, frameworks JS, analytics, CDN — desde el HTML + cabeceras + cookies, sin ejecutar JS. El pipeline `fingerprint_web_stack` hace fetch + matching de firmas en una llamada. + +```bash +cd /home/enmanuel/fn_registry +python/.venv/bin/python3 - <<'PYEOF' +import sys +sys.path.insert(0, "python/functions") +from pipelines.fingerprint_web_stack import fingerprint_web_stack + +res = fingerprint_web_stack("https://example.com", save=True) +print(res["status"], "->", res.get("count"), "tecnologias") +for t in res.get("technologies", []): + print(f" {t['name']} [{t['category']}] v={t['version']!r} ({t['confidence']})") +PYEOF +``` + +Las dos capas tambien sueltas: `fetch_http_fingerprint(url)` para inspeccionar cabeceras+html+cookies crudos de una URL, y `detect_web_tech(headers, html, cookies)` (pura) para matchear firmas sobre señales ya recogidas (testeable sin red). + +> Limite: un fetch estatico NO ejecuta JavaScript. Una SPA que monta su framework en runtime (React/Vue con HTML inicial vacio) puede no detectarse. Para esos casos, recoger el DOM renderizado via el grupo `browser` (CDP) y pasar ese html a `detect_web_tech`. + +## Integracion OSINT + +Cada escaneo guardado acaba en **dos sitios**, y por eso `save_scan_to_osint` (y el pipeline `recon_osint`) son el cierre obligatorio del grupo: + +1. **Nota Markdown en el vault** `~/Obsidian/osint` bajo + `dominios//recon/-.md`. Frontmatter tipado + (`tipo: scan-red`, `scan_tipo`, `target`, `slug`, `fecha`, `herramienta`, + `tags: [scan-red, , recon]`) y el `raw` del scan en un bloque de + codigo. Es la **capa critica**: si falla, el sink devuelve `status:error`. +2. **Fila en la tabla DuckDB `network_scans`** (schema `main`) del service + `osint_db`, via `POST http://127.0.0.1:8771/api/scan`. Columnas: + `id, target, target_slug, scan_type, tool, scan_ts, note_path, summary(JSON), + created_at`. Es la **capa best-effort**: si el service esta caido o no expone + el endpoint, el sink degrada a solo-nota con `registered=False` + + `register_warning`, sin romper. El re-ingest del vault NO borra esta tabla. + +**REGLA: todo escaneo se guarda en OSINT.** No hay scans "sueltos". O usas el +pipeline `recon_osint` (scan + archivado en 1 call), o llamas la funcion de scan +atomica y a continuacion `save_scan_to_osint` con su `raw`. El slug del target se +deriva con `re.sub(r"[^a-z0-9._-]+", "-", target.lower())`. + +## Escaneos nmap utiles para segundo plano + +Los perfiles pesados de `nmap_scan` deben lanzarse en background (`&` / `nohup` / `run_in_background`) por su duracion. Pasa `out_dir` para conservar el XML y sube `timeout_s`. + +| Perfil | Flags nmap | Cuando usarlo | Duracion | +|---|---|---|---| +| `full-tcp` | `-p- -T4` | Mapear los 65535 puertos TCP (no solo el top 1000). Cuando buscas servicios en puertos no estandar. | Minutos a horas → background | +| `vuln` | `-sV --script vuln -T4` | Correr los scripts NSE de vulnerabilidades sobre los servicios detectados. Fase posterior a un service scan. | Largo, ruidoso → background | +| `udp-top` | `-sU --top-ports 100 -T4` | Descubrir servicios UDP (DNS, SNMP, NTP...). UDP es lento y suele requerir sudo. | Largo → background | +| `service` | `-sV -sC -T4` | Deteccion de version + scripts default sobre puertos abiertos. A veces tolerable en primer plano. | Medio (puede ir a background) | +| `aggressive` | `-A -T4` | OS + version + scripts + traceroute de golpe. Muy detectable; el `-O` interno puede pedir sudo. | Largo, ruidoso → background | + +Perfiles ligeros que SI corren bien en primer plano: `quick` (`-T4 -F`, top 100), `top1000` (`-T4`), `discovery` (`-sn`, ping sweep de una subred → puebla `hosts_up`), `os` (`-O`, requiere sudo). + +## Prerequisitos + +- **CLIs instaladas** en el PATH: `whois` (`apt install whois`), `rdap` (openrdap, normalmente en `~/go/bin/rdap` — `go install github.com/openrdap/rdap/cmd/rdap@latest`), `dig` (`dnsutils`/`bind-utils`), `ping` (`iputils-ping`), `traceroute`, `nmap`. Si falta el binario, la funcion devuelve `status:error` con la instruccion de instalacion, nunca lanza. +- **Privilegios**: los perfiles de nmap `os` (-O), `udp-top` (-sU) y parte de `aggressive` requieren sudo/root; sin privilegios nmap cae a connect-scan TCP y esos modos quedan incompletos (estas funciones no usan sudo). +- **Service `osint_db` vivo** en `http://127.0.0.1:8771` para el registro estructurado en `network_scans`. Si esta caido, los scans siguen guardandose como nota (solo se pierde la fila DuckDB hasta el siguiente re-registro). Ver memoria `osint-duckdb-stack`. + +## Fronteras (que NO cubre) + +- **No es un framework de explotacion.** Es reconocimiento: identifica superficie (puertos, servicios, versiones, registro, ruta). No explota vulnerabilidades, no hace fuerza bruta de credenciales, no entrega payloads. Para eso, herramientas dedicadas fuera del registry. +- **Solo hosts autorizados o propios.** Escanear infraestructura de terceros sin permiso explicito puede ser delito. `scanme.nmap.org` es el unico host de terceros legal por defecto (es el host oficial de pruebas de nmap). +- **No evade deteccion.** No implementa tecnicas de evasion de IDS/WAF, fragmentacion, decoys ni timing de sigilo; `-T4` es ruidoso a proposito. Un objetivo que defienda activamente puede detectar y filtrar el escaneo. +- **No cubre OSINT pasivo de personas** (dorks, usernames, emails) mas alla de listar las funciones afines: esas viven en el grupo `osint-passive`. El render BD→nota y el grafo del vault son de `obsidian`/`duckdb`. diff --git a/docs/capabilities/sink.md b/docs/capabilities/sink.md index fc634aef..af5060ce 100644 --- a/docs/capabilities/sink.md +++ b/docs/capabilities/sink.md @@ -19,6 +19,7 @@ Filtro MCP: `mcp__registry__fn_search query="" tag="sink"`. | [http_post_json_py_infra](../../python/functions/infra/http_post_json.md) | py | HTTP JSON POST | | [http_post_json_go_infra](../../functions/infra/http_post_json.md) | go | HTTP JSON POST | | [db_insert_row_go_infra](../../functions/infra/db_insert_row.md) | go | SQL row insert | +| [save_scan_to_osint_py_cybersecurity](../../python/functions/cybersecurity/save_scan_to_osint.md) | py | Vault Obsidian (nota) + osint_db (DuckDB via HTTP) — sink de scans de red | ## Ejemplo canonico diff --git a/python/functions/cybersecurity/__init__.py b/python/functions/cybersecurity/__init__.py index 096182b8..4900d068 100644 --- a/python/functions/cybersecurity/__init__.py +++ b/python/functions/cybersecurity/__init__.py @@ -32,6 +32,18 @@ from .whois_lookup import whois_lookup from .dns_records import dns_records from .enum_subdomains_crtsh import enum_subdomains_crtsh +# Active recon (grupo recon). +from .nmap_scan import nmap_scan +from .rdap_lookup import rdap_lookup +from .ping_host import ping_host +from .traceroute_host import traceroute_host +from .scan_tcp_ports import scan_tcp_ports +from .grab_service_banner import grab_service_banner +from .identify_port_service import identify_port_service +from .save_scan_to_osint import save_scan_to_osint +from .fetch_http_fingerprint import fetch_http_fingerprint +from .detect_web_tech import detect_web_tech + # OSINT passive enrichment orchestrators (grupo osint-enrich). from .scan_ficha_attachments_metadata import scan_ficha_attachments_metadata from .enrich_person_passive import enrich_person_passive @@ -67,6 +79,16 @@ __all__ = [ "whois_lookup", "dns_records", "enum_subdomains_crtsh", + "nmap_scan", + "rdap_lookup", + "ping_host", + "traceroute_host", + "scan_tcp_ports", + "grab_service_banner", + "identify_port_service", + "save_scan_to_osint", + "fetch_http_fingerprint", + "detect_web_tech", "scan_ficha_attachments_metadata", "enrich_person_passive", "enrich_org_passive", diff --git a/python/functions/cybersecurity/detect_web_tech.md b/python/functions/cybersecurity/detect_web_tech.md new file mode 100644 index 00000000..880c1682 --- /dev/null +++ b/python/functions/cybersecurity/detect_web_tech.md @@ -0,0 +1,111 @@ +--- +name: detect_web_tech +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: pure +signature: "def detect_web_tech(headers: dict, html: str = '', cookies: list[str] | None = None, final_url: str = '') -> dict" +description: "Detector de tecnologia web estilo Wappalyzer: identifica el stack tecnologico de un sitio (web fingerprint) matcheando una tabla de firmas regex embebida contra las cabeceras HTTP, el HTML, los nombres de cookies y la URL final. Detecta servidor (nginx, Apache, IIS, LiteSpeed, Caddy), lenguaje (PHP, ASP.NET, Java, Python, Ruby, Node.js), CMS (WordPress, Drupal, Joomla, Shopify, Wix, Squarespace, Ghost), frameworks JS (React, Vue, Angular, Svelte, Next.js, Nuxt), librerias (jQuery, Bootstrap, Lodash, Modernizr), analytics/tag (Google Analytics, GTM, Facebook Pixel, Hotjar, Matomo), CDN (Cloudflare, Fastly, Akamai, CloudFront, jsDelivr, unpkg), ecommerce (WooCommerce, Magento, PrestaShop, Shopify) y WAF/seguridad (Cloudflare, Sucuri, Imperva Incapsula). Pieza pura del detector: no toca la red, recibe las senales ya recogidas por fetch_http_fingerprint." +tags: [recon, cybersecurity, web-recon, wappalyzer, fingerprint, tech-detection, cms, stack] +params: + - name: headers + desc: "dict de cabeceras de respuesta HTTP con claves en minusculas (tal como las devuelve fetch_http_fingerprint en su campo headers). Valores string. Si las claves vienen en mayusculas se normalizan internamente." + - name: html + desc: "HTML de la pagina como string. Default '' para detectar solo por cabeceras y cookies. De aqui se extraen meta generator y src de los ' + result = detect_web_tech({}, html=html) + jq = _by_name(result, "jQuery") + assert jq is not None + assert jq["confidence"] == "medium" + assert jq["version"] == "3.6.0" diff --git a/python/functions/cybersecurity/dns_records.md b/python/functions/cybersecurity/dns_records.md index f3e3eba5..1fbd8e2d 100644 --- a/python/functions/cybersecurity/dns_records.md +++ b/python/functions/cybersecurity/dns_records.md @@ -3,25 +3,27 @@ name: dns_records kind: function lang: py domain: cybersecurity -version: "1.0.0" +version: "2.0.0" purity: impure -signature: "def dns_records(dominio: str, types: list | None = None) -> dict" -description: "Recoleccion OSINT pasiva de registros DNS de un dominio ejecutando `dig +short ` por subprocess para cada tipo (default A, AAAA, MX, TXT, NS, CNAME). Parsea la salida (una linea por valor) y devuelve un dict {tipo: [valores]}. Pasivo: solo consulta DNS publico." -tags: [osint-passive, dns, recon, cybersecurity, dig] +signature: "def dns_records(domain: str, record_types: list[str] | None = None, timeout_s: int = 20) -> dict" +description: "Recoleccion OSINT pasiva de registros DNS de un dominio ejecutando `dig +short ` por subprocess para cada tipo (default A, AAAA, MX, NS, SOA, TXT, CNAME). Devuelve dict de estado {status, domain, records:{TYPE:[valores]}, raw} sin lanzar; `raw` concatena un bloque legible por tipo para guardar la evidencia en un vault OSINT. Pasivo: solo consulta DNS publico." +tags: [recon, dns, cybersecurity, osint-passive, dig] params: - - name: dominio - desc: "Dominio a resolver, ej. organic-machine.com. Vacio lanza RuntimeError." - - name: types - desc: "Lista de tipos de registro DNS (ej. ['A','MX']). None usa los 6 defaults: A, AAAA, MX, TXT, NS, CNAME." -output: "dict {tipo: [valores]} con una clave por tipo consultado; cada valor es la lista de lineas devueltas por `dig +short` para ese tipo (lista vacia si no hay registro o el dominio no existe)." + - name: domain + desc: "Dominio a resolver, ej. google.com. Vacio devuelve status error." + - name: record_types + desc: "Lista de tipos de registro DNS (ej. ['A','MX']). None usa los 7 defaults: A, AAAA, MX, NS, SOA, TXT, CNAME." + - name: timeout_s + desc: "Timeout total en segundos repartido entre las consultas (cada dig recibe timeout_s/N, minimo 2s)." +output: "dict de estado. En exito {status:'ok', domain, records:{TYPE:[valores]}, raw}: records es un dict por tipo con la lista de lineas de `dig +short` (lista vacia si no hay registro o el dominio no existe); raw es texto '=== TYPE ===\\n...' por cada tipo. En fallo {status:'error', error:str, raw:''}." uses_functions: [] uses_types: [] returns: [] returns_optional: false -error_type: "error_go_core" +error_type: "error_py_core" imports: [] tested: true -tests: ["test_parsea_salida_de_dig", "test_dominio_inexistente_listas_vacias", "test_usa_tipos_default", "test_timeout_devuelve_lista_vacia", "test_dominio_vacio_lanza_error"] +tests: ["test_parsea_salida_de_dig", "test_dominio_inexistente_listas_vacias", "test_usa_tipos_default", "test_timeout_devuelve_lista_vacia", "test_dominio_vacio_status_error"] test_file_path: "python/functions/cybersecurity/dns_records_test.py" file_path: "python/functions/cybersecurity/dns_records.py" --- @@ -34,25 +36,42 @@ sys.path.insert(0, os.path.join("python", "functions")) from cybersecurity import dns_records # Resolver todos los tipos por defecto -records = dns_records("organic-machine.com") -print(records["A"]) # ['135.125.201.30'] -print(records["MX"]) # ['10 mail.organic-machine.com.', ...] +res = dns_records("google.com") +print(res["status"]) # "ok" +print(res["records"]["A"]) # ['142.250.x.x', ...] +print(res["records"]["MX"]) # ['10 smtp.google.com.', ...] +print(res["raw"]) # bloque "=== A ===\n...\n=== MX ===\n..." para el vault # Solo los tipos que interesan -solo_a_mx = dns_records("organic-machine.com", types=["A", "MX"]) +solo_a_mx = dns_records("google.com", record_types=["A", "MX"], timeout_s=10) ``` ## Cuando usarla Usala al iniciar el reconocimiento pasivo de un dominio para mapear su -infraestructura DNS publica (IPs, servidores de correo, nameservers, TXT con -SPF/DKIM/verificaciones). Es el primer paso barato antes de enumerar -subdominios o consultar RDAP. +infraestructura DNS publica (IPs, servidores de correo, nameservers, SOA, TXT +con SPF/DKIM/verificaciones). Es el primer paso barato antes de enumerar +subdominios o consultar RDAP. Guarda `raw` directamente en la nota OSINT como +evidencia. ## Gotchas -- Requiere el binario `dig` en PATH (paquete `dnsutils`/`bind-utils`). Si falta, lanza `RuntimeError`. -- Cada consulta tiene timeout de 10s; si una expira, esa clave queda como lista vacia y el resto continua. -- La salida es la de `dig +short` cruda: los MX incluyen prioridad ("10 mail..."), los nombres pueden venir con punto final (FQDN), y los TXT entre comillas. No se normaliza para mantener fidelidad. -- Un dominio inexistente o sin un registro concreto devuelve lista vacia (no error): distingue "sin datos" mirando las listas vacias. -- Resuelve contra el resolver configurado en el sistema; resultados pueden variar segun el DNS recursivo usado. +- Funcion impura: hace red (consulta DNS via `dig`). No determinista entre + resolvers ni en el tiempo (TTL, propagacion). +- Requiere el binario `dig` en PATH (paquete `dnsutils`/`bind-utils`). Si + falta, devuelve `{"status":"error",...}` (no lanza). +- Nunca lanza: errores se reportan en `status`. Un dominio inexistente o sin un + registro concreto devuelve `status:"ok"` con listas vacias — distingue + "sin datos" mirando las listas, no el status. +- La salida es la de `dig +short` cruda: los MX incluyen prioridad + ("10 mail..."), los nombres pueden venir con punto final (FQDN), y los TXT + entre comillas. No se normaliza para mantener fidelidad. +- El `timeout_s` se reparte entre las N consultas (minimo 2s por consulta); si + una expira, esa clave queda como lista vacia, su bloque `raw` dice "(timeout)" + y el resto continua. +- Resuelve contra el resolver configurado en el sistema; resultados pueden + variar segun el DNS recursivo usado. + +## Capability growth log + +v2.0.0 (2026-06-14) — reescritura del contrato: firma `(domain, record_types, timeout_s)`, retorno dict de estado `{status, domain, records, raw}` sin lanzar (antes `{tipo:[valores]}` con RuntimeError), `raw` legible por tipo para vault OSINT, default amplia a NS+SOA, `error_type` pasa a `error_py_core`. diff --git a/python/functions/cybersecurity/dns_records.py b/python/functions/cybersecurity/dns_records.py index 108aae4c..52e1f235 100644 --- a/python/functions/cybersecurity/dns_records.py +++ b/python/functions/cybersecurity/dns_records.py @@ -1,63 +1,106 @@ """Recoleccion OSINT pasiva de registros DNS via el binario `dig`. -Funcion IMPURA: ejecuta `dig +short ` como subprocess para -cada tipo de registro y parsea la salida (una linea por valor). Es OSINT -pasivo: consulta DNS publico, no envia trafico intrusivo al objetivo. +Funcion IMPURA: para cada tipo de registro ejecuta `dig +short ` +como subprocess y parsea la salida (una linea por valor). Es OSINT pasivo: +consulta DNS publico, no envia trafico intrusivo al objetivo. + +Nunca lanza: devuelve un dict con `status` ("ok"/"error"). El campo `raw` +siempre esta presente para guardar la evidencia legible en un vault OSINT. """ import subprocess -DEFAULT_TYPES = ["A", "AAAA", "MX", "TXT", "NS", "CNAME"] +DEFAULT_TYPES = ["A", "AAAA", "MX", "NS", "SOA", "TXT", "CNAME"] -def dns_records(dominio: str, types: list | None = None) -> dict: +def dns_records( + domain: str, + record_types: list[str] | None = None, + timeout_s: int = 20, +) -> dict: """Resuelve registros DNS de un dominio ejecutando `dig +short`. - Para cada tipo en ``types`` ejecuta ``dig +short `` y - parsea la salida: cada linea no vacia es un valor del registro. Un - dominio inexistente (o un registro ausente) produce una lista vacia. + Para cada tipo en ``record_types`` ejecuta ``dig +short `` + y parsea la salida: cada linea no vacia es un valor del registro. Un + dominio o registro inexistente produce una lista vacia (no error). Args: - dominio: Dominio a resolver (ej. ``"organic-machine.com"``). - types: Lista de tipos de registro DNS (ej. ``["A", "MX"]``). Si es - None usa los defaults: A, AAAA, MX, TXT, NS, CNAME. + domain: Dominio a resolver (ej. ``"google.com"``). + record_types: Lista de tipos de registro DNS (ej. ``["A", "MX"]``). + None usa los defaults: A, AAAA, MX, NS, SOA, TXT, CNAME. + timeout_s: Timeout total en segundos repartido entre las consultas + (cada `dig` recibe una porcion del presupuesto, minimo 2s). Returns: - Dict ``{tipo: [valores]}`` con una clave por tipo consultado. Cada - valor es la lista de lineas devueltas por dig para ese tipo. + Dict de estado. En exito:: - Raises: - RuntimeError: Si el binario `dig` no esta instalado o el dominio - esta vacio. + { + "status": "ok", + "domain": , + "records": {"A": [...], "MX": [...], ...}, + "raw": "=== A ===\\n...\\n=== MX ===\\n...", + } + + En fallo (binario ausente, dominio vacio):: + + {"status": "error", "error": , "raw": ""} """ - if not dominio or not dominio.strip(): - raise RuntimeError("dns_records: dominio vacio") + if not domain or not domain.strip(): + return {"status": "error", "error": "dns_records: domain vacio", "raw": ""} - query_types = types if types is not None else list(DEFAULT_TYPES) - result: dict = {} + domain = domain.strip() + query_types = record_types if record_types is not None else list(DEFAULT_TYPES) + per_query_timeout = max(2.0, float(timeout_s) / max(1, len(query_types))) + + records: dict[str, list[str]] = {} + raw_parts: list[str] = [] for record_type in query_types: + rt = record_type.strip().upper() try: proc = subprocess.run( - ["dig", "+short", record_type, dominio], + ["dig", "+short", domain, rt], capture_output=True, text=True, - timeout=10.0, + timeout=per_query_timeout, ) - except FileNotFoundError as e: - raise RuntimeError( - "dns_records: binario `dig` no encontrado en PATH" - ) from e + values = [ + line.strip() + for line in proc.stdout.splitlines() + if line.strip() + ] + section_body = proc.stdout.rstrip("\n") if proc.stdout.strip() else "(sin registros)" + except FileNotFoundError: + return { + "status": "error", + "error": "dns_records: binario `dig` no encontrado en PATH (paquete dnsutils)", + "raw": "", + } except subprocess.TimeoutExpired: - # Timeout en una consulta concreta: dejamos lista vacia y seguimos. - result[record_type] = [] - continue + values = [] + section_body = "(timeout)" - values = [ - line.strip() - for line in proc.stdout.splitlines() - if line.strip() - ] - result[record_type] = values + records[rt] = values + raw_parts.append(f"=== {rt} ===\n{section_body}") - return result + return { + "status": "ok", + "domain": domain, + "records": records, + "raw": "\n".join(raw_parts), + } + + +if __name__ == "__main__": + try: + result = dns_records("google.com", record_types=["A", "MX", "NS", "TXT"]) + print(result["status"]) + if result["status"] == "ok": + print("A:", result["records"].get("A")) + print("MX:", result["records"].get("MX")) + print("--- raw ---") + print(result["raw"]) + else: + print("error:", result.get("error")) + except Exception as exc: # smoke: tolera cualquier fallo de red sin romper + print("smoke fallo (tolerado):", exc) diff --git a/python/functions/cybersecurity/dns_records_test.py b/python/functions/cybersecurity/dns_records_test.py index 5d5685bf..932e8703 100644 --- a/python/functions/cybersecurity/dns_records_test.py +++ b/python/functions/cybersecurity/dns_records_test.py @@ -24,48 +24,56 @@ def test_parsea_salida_de_dig(monkeypatch): } def fake_run(cmd, **kwargs): - record_type = cmd[2] + # cmd = ["dig", "+short", domain, TYPE] + record_type = cmd[3] return _FakeProc(fixtures.get(record_type, "")) monkeypatch.setattr(subprocess, "run", fake_run) - result = dns_records("organic-machine.com", types=["A", "MX", "TXT"]) + res = dns_records("organic-machine.com", record_types=["A", "MX", "TXT"]) - assert result["A"] == ["135.125.201.30"] - assert result["MX"] == [ + assert res["status"] == "ok" + assert res["domain"] == "organic-machine.com" + assert res["records"]["A"] == ["135.125.201.30"] + assert res["records"]["MX"] == [ "10 mail.organic-machine.com.", "20 mail2.organic-machine.com.", ] - assert result["TXT"] == ['"v=spf1 -all"'] + assert res["records"]["TXT"] == ['"v=spf1 -all"'] + assert "=== A ===" in res["raw"] + assert "=== MX ===" in res["raw"] def test_dominio_inexistente_listas_vacias(monkeypatch): - """Salida vacia de dig (dominio inexistente) produce listas vacias.""" + """Salida vacia de dig (dominio inexistente) produce listas vacias, status ok.""" def fake_run(cmd, **kwargs): return _FakeProc("") monkeypatch.setattr(subprocess, "run", fake_run) - result = dns_records("nope-no-existe-xyz.invalid", types=["A", "AAAA"]) + res = dns_records("nope-no-existe-xyz.invalid", record_types=["A", "AAAA"]) - assert result == {"A": [], "AAAA": []} + assert res["status"] == "ok" + assert res["records"] == {"A": [], "AAAA": []} + assert "(sin registros)" in res["raw"] def test_usa_tipos_default(monkeypatch): - """Sin types consulta los 6 tipos por defecto.""" + """Sin record_types consulta los 7 tipos por defecto.""" consultados = [] def fake_run(cmd, **kwargs): - consultados.append(cmd[2]) + consultados.append(cmd[3]) return _FakeProc("") monkeypatch.setattr(subprocess, "run", fake_run) - result = dns_records("organic-machine.com") + res = dns_records("organic-machine.com") - assert set(result.keys()) == {"A", "AAAA", "MX", "TXT", "NS", "CNAME"} - assert consultados == ["A", "AAAA", "MX", "TXT", "NS", "CNAME"] + assert res["status"] == "ok" + assert set(res["records"].keys()) == {"A", "AAAA", "MX", "NS", "SOA", "TXT", "CNAME"} + assert consultados == ["A", "AAAA", "MX", "NS", "SOA", "TXT", "CNAME"] def test_timeout_devuelve_lista_vacia(monkeypatch): @@ -76,15 +84,16 @@ def test_timeout_devuelve_lista_vacia(monkeypatch): monkeypatch.setattr(subprocess, "run", fake_run) - result = dns_records("organic-machine.com", types=["A"]) + res = dns_records("organic-machine.com", record_types=["A"]) - assert result == {"A": []} + assert res["status"] == "ok" + assert res["records"] == {"A": []} + assert "(timeout)" in res["raw"] -def test_dominio_vacio_lanza_error(): - """Dominio vacio lanza RuntimeError.""" - try: - dns_records("") - assert False, "deberia haber lanzado RuntimeError" - except RuntimeError: - pass +def test_dominio_vacio_status_error(): + """Dominio vacio devuelve status error sin lanzar.""" + res = dns_records("") + assert res["status"] == "error" + assert res["raw"] == "" + assert "domain" in res["error"] diff --git a/python/functions/cybersecurity/fetch_http_fingerprint.md b/python/functions/cybersecurity/fetch_http_fingerprint.md new file mode 100644 index 00000000..bb81a14c --- /dev/null +++ b/python/functions/cybersecurity/fetch_http_fingerprint.md @@ -0,0 +1,90 @@ +--- +name: fetch_http_fingerprint +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: impure +signature: "def fetch_http_fingerprint(url: str, timeout_s: float = 15.0, verify_tls: bool = True, max_html_bytes: int = 500_000, user_agent: str | None = None) -> dict" +description: "Hace un GET HTTP(S) a una URL con User-Agent de navegador, sigue redirects y recoge TODAS las senales crudas para fingerprint de la tecnologia web (estilo Wappalyzer): cabeceras HTTP de respuesta normalizadas (lowercase), nombres de cookies, el HTML, el titulo y la cadena del servidor. Es la capa IMPURA (toca la red) del fingerprinting web / deteccion de stack tecnologico; la capa de matching de firmas es la funcion pura aparte detect_web_tech_py_cybersecurity que consume exactamente lo que esta devuelve. Descomprime gzip/deflate y decodifica el HTML best-effort. Nunca lanza: devuelve dict {status: ok|error}; un 403/500 sigue siendo senal util y se devuelve con su status_code real. SEGURIDAD: en cookies solo guarda los NOMBRES, nunca los valores. Solo stdlib (urllib, ssl, re, gzip, zlib)." +tags: [recon, cybersecurity, web-recon] +params: + - name: url + desc: "URL objetivo. Si no trae esquema se asume https:// y, si la conexion HTTPS falla, reintenta con http://. Vacia devuelve status error." + - name: timeout_s + desc: "Timeout de la peticion en segundos (default 15.0)." + - name: verify_tls + desc: "Si False crea un ssl context sin verificacion de certificado (inseguro, vulnerable a MITM; solo para recon de hosts propios con cert self-signed). Default True." + - name: max_html_bytes + desc: "Corta el HTML leido a este tamano en bytes para no descargar megas (default 500_000 = 500 KB). Las SPAs grandes pueden quedar truncadas." + - name: user_agent + desc: "User-Agent a enviar. Default un UA realista de Chrome desktop." +output: "dict. En exito: {status: 'ok', url, final_url (tras redirects), status_code (int), headers (dict claves lowercase, valores str, ultimo si repetido), cookies (lista de SOLO nombres de cookie de Set-Cookie, nunca valores), title (str|None), server (str|None, atajo a headers['server']), html (str cortado a max_html_bytes), html_len (int), raw (bloque legible status+headers SIN el html, para evidencia OSINT)}. Un error HTTP (403/404/500...) devuelve status ok con su status_code real. En error de red total (host no resuelve / conexion rechazada / timeout): {status: 'error', error: str, url}." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_status_ok_y_status_code_200", "test_headers_normalizados_lowercase", "test_cookies_solo_nombres_no_valores", "test_title_extraido", "test_url_vacia_devuelve_error", "test_host_inexistente_devuelve_error_sin_lanzar"] +test_file_path: "python/functions/cybersecurity/fetch_http_fingerprint_test.py" +file_path: "python/functions/cybersecurity/fetch_http_fingerprint.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity import fetch_http_fingerprint + +fp = fetch_http_fingerprint("https://example.com") +if fp["status"] == "ok": + print(fp["status_code"]) # 200 + print(fp["final_url"]) # https://www.example.com/ (tras redirects) + print(fp["server"]) # 'nginx' (o None) + print(fp["headers"].get("x-powered-by")) # 'PHP/8.1' (o None) + print(fp["title"]) # titulo de la pagina + print(fp["cookies"]) # ['PHPSESSID', 'cf_clearance'] (SOLO nombres) + # fp["html"] / fp["headers"] alimentan detect_web_tech para el matching de firmas. + # fp["raw"] tiene status + headers (sin html) para guardar como evidencia OSINT. +else: + print("fallo:", fp["error"]) +``` + +## Cuando usarla + +Usala como **primer paso del fingerprint de la tecnologia web** de un sitio: +recoge headers + html + cookies crudos para que +`detect_web_tech_py_cybersecurity` los matchee contra firmas (estilo +Wappalyzer) e identifique CMS, frameworks, servidores, CDNs, lenguajes, etc. +Tambien es util **sola** para inspeccionar las cabeceras HTTP, el titulo y el +servidor de una URL durante recon, o para conservar la respuesta (`raw`) como +evidencia. Sigue redirects, asi que tambien revela el destino final de una URL. + +## Gotchas + +- IMPURA: hace red. Nunca lanza — fallos de red total devuelven + `{"status": "error", ...}`. Un error HTTP (403/500...) se devuelve como + `status: ok` con su `status_code` real porque sigue siendo senal de + fingerprint. +- **`cookies` guarda SOLO los nombres**, nunca los valores. Un Set-Cookie lleva + tokens de sesion; capturar el valor seria filtrar un secreto. El bloque `raw` + tampoco incluye valores de cookie. +- **`verify_tls=False` es inseguro** (vulnerable a MITM): desactiva la + verificacion del certificado TLS. Usalo solo en recon de hosts propios con + cert self-signed, nunca contra objetivos en internet. +- **Sigue redirects** (urllib por defecto): el `final_url` puede saltar a otro + dominio/host distinto del solicitado. Comprueba `final_url` si el scope + importa. +- **`max_html_bytes` corta el HTML** (default 500 KB): SPAs grandes o paginas + con mucho inline pueden quedar truncadas, y el matching de firmas que dependa + del final del documento puede fallar. Sube el limite si lo necesitas. +- Un **WAF / anti-bot** (Cloudflare, etc.) puede devolver una pagina challenge + en vez del sitio real; en ese caso el fingerprint reflejara el WAF, no el + stack subyacente. +- **Legal**: respeta robots, el scope autorizado y la autorizacion legal del + objetivo antes de escanear. Es trafico activo contra el host (un GET real). +- Fallback de esquema: una `url` sin `://` se intenta primero como `https://` y, + si falla la conexion, como `http://`. diff --git a/python/functions/cybersecurity/fetch_http_fingerprint.py b/python/functions/cybersecurity/fetch_http_fingerprint.py new file mode 100644 index 00000000..c8633017 --- /dev/null +++ b/python/functions/cybersecurity/fetch_http_fingerprint.py @@ -0,0 +1,295 @@ +"""GET HTTP(S) que recoge senales crudas para fingerprint de tecnologia web. + +Funcion IMPURA: hace una peticion HTTP(S) GET a una URL con User-Agent de +navegador, sigue redirects y recoge TODAS las senales utiles para identificar +el stack tecnologico del sitio (estilo Wappalyzer): cabeceras de respuesta +normalizadas (lowercase), nombres de cookies, el HTML, el titulo y la cadena +del servidor. Es la capa de RECOLECCION del fingerprinting web; el MATCHING de +firmas vive en una funcion pura aparte (`detect_web_tech_py_cybersecurity`) +que consume exactamente lo que esta devuelve. + +Devuelve siempre un dict (estilo del grupo recon): nunca lanza excepciones. +Un 403/500 sigue siendo senal util de fingerprint, asi que un HTTPError se +captura y se devuelve con su status_code real, headers y body. + +SEGURIDAD: en `cookies` solo se guardan los NOMBRES de las cookies, jamas los +valores (un Set-Cookie lleva tokens de sesion sensibles). + +Solo usa stdlib (urllib, ssl, re, gzip, zlib). +""" + +import gzip +import re +import socket +import ssl +import urllib.error +import urllib.request +import zlib + +_DEFAULT_UA = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" +) +_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +_CHARSET_RE = re.compile(r"charset=([\w-]+)", re.IGNORECASE) +_COOKIE_NAME_RE = re.compile(r"^\s*([^=;\s]+)=") + + +def _decompress(body: bytes, encoding: str) -> bytes: + """Descomprime el body segun Content-Encoding (gzip/deflate). Best-effort.""" + enc = (encoding or "").lower() + try: + if "gzip" in enc: + return gzip.decompress(body) + if "deflate" in enc: + # deflate puede venir con o sin cabecera zlib. + try: + return zlib.decompress(body) + except zlib.error: + return zlib.decompress(body, -zlib.MAX_WBITS) + except (OSError, zlib.error): + # Si la descompresion falla, devuelve el body crudo (mejor algo que nada). + return body + return body + + +def _decode_html(body: bytes, content_type: str) -> str: + """Decodifica el HTML best-effort: charset del Content-Type -> utf-8 -> latin-1.""" + charset = None + m = _CHARSET_RE.search(content_type or "") + if m: + charset = m.group(1).strip() + for enc in (charset, "utf-8", "latin-1"): + if not enc: + continue + try: + return body.decode(enc, errors="strict") + except (LookupError, UnicodeDecodeError): + continue + # latin-1 nunca falla; ultimo recurso explicito. + return body.decode("latin-1", errors="replace") + + +def _extract_title(html: str) -> str | None: + """Extrae el contenido de best-effort, colapsando espacios.""" + m = _TITLE_RE.search(html) + if not m: + return None + title = re.sub(r"\s+", " ", m.group(1)).strip() + return title or None + + +def _cookie_names(set_cookie_values: list[str]) -> list[str]: + """Devuelve solo los NOMBRES de las cookies (nunca valores), deduplicados en orden.""" + out: list[str] = [] + seen: set[str] = set() + for raw in set_cookie_values: + m = _COOKIE_NAME_RE.match(raw or "") + if not m: + continue + name = m.group(1) + if name and name not in seen: + seen.add(name) + out.append(name) + return out + + +def _normalize_headers(headers) -> tuple[dict, list[str]]: + """Normaliza headers a {clave_lower: valor_str} y extrae los Set-Cookie crudos. + + Si una cabecera se repite, gana el ultimo valor (salvo Set-Cookie, que se + acumula aparte para extraer todos los nombres de cookie). Devuelve + (headers_dict, lista_de_set_cookie_crudos). + """ + norm: dict[str, str] = {} + set_cookies: list[str] = [] + # http.client.HTTPMessage soporta .items() devolviendo cada par (con repetidos). + for key, value in headers.items(): + lk = key.lower() + if lk == "set-cookie": + set_cookies.append(value) + continue + norm[lk] = value + return norm, set_cookies + + +def _build_raw(status_line: str, headers: dict, cookie_names: list[str]) -> str: + """Construye un bloque legible (status + headers + nombres de cookie) para evidencia. + + NO incluye el HTML entero (puede ser megas) ni valores de cookie (sensibles). + """ + lines = [status_line] + for k in sorted(headers): + lines.append(f"{k}: {headers[k]}") + if cookie_names: + lines.append("set-cookie-names: " + ", ".join(cookie_names)) + return "\n".join(lines) + + +def _do_get( + url: str, + timeout_s: float, + verify_tls: bool, + max_html_bytes: int, + ua: str, +) -> dict: + """Hace un GET unico a `url` y construye el dict de salida. Puede lanzar.""" + req = urllib.request.Request( + url, + headers={ + "User-Agent": ua, + "Accept": ( + "text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,*/*;q=0.8" + ), + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate", + }, + method="GET", + ) + context = None if verify_tls else ssl._create_unverified_context() + + try: + resp = urllib.request.urlopen(req, timeout=timeout_s, context=context) + status_code = resp.getcode() + final_url = resp.geturl() + resp_headers = resp.headers + body = resp.read(max_html_bytes + 1) + resp.close() + except urllib.error.HTTPError as e: + # Un error HTTP (403/404/500...) SIGUE siendo senal util de fingerprint: + # tiene headers y a menudo body. Lo tratamos como respuesta valida. + status_code = e.code + final_url = e.geturl() or url + resp_headers = e.headers + body = e.read(max_html_bytes + 1) if e.fp is not None else b"" + + headers, set_cookie_raw = _normalize_headers(resp_headers) + cookie_names = _cookie_names(set_cookie_raw) + + content_encoding = headers.get("content-encoding", "") + body = _decompress(body, content_encoding) + if len(body) > max_html_bytes: + body = body[:max_html_bytes] + + html = _decode_html(body, headers.get("content-type", "")) + title = _extract_title(html) + server = headers.get("server") + + status_line = f"HTTP {status_code} {final_url}" + raw = _build_raw(status_line, headers, cookie_names) + + return { + "status": "ok", + "url": url, + "final_url": final_url, + "status_code": status_code, + "headers": headers, + "cookies": cookie_names, + "title": title, + "server": server, + "html": html, + "html_len": len(html), + "raw": raw, + } + + +def fetch_http_fingerprint( + url: str, + timeout_s: float = 15.0, + verify_tls: bool = True, + max_html_bytes: int = 500_000, + user_agent: str | None = None, +) -> dict: + """GET HTTP(S) que recoge senales crudas para fingerprint de tecnologia web. + + Funcion IMPURA: hace red. Manda un GET con User-Agent de navegador, sigue + redirects (urllib los sigue por defecto) y recoge headers normalizados, + nombres de cookies, HTML, titulo y servidor. Nunca lanza: cualquier fallo + de red total devuelve ``{"status": "error", ...}``. Un error HTTP + (403/500...) se devuelve como ``status: ok`` con su ``status_code`` real, + porque sigue siendo senal de fingerprint. + + Si `url` no trae esquema, asume ``https://`` y, si la conexion HTTPS falla, + reintenta con ``http://``. + + Args: + url: URL objetivo. Sin esquema se asume https:// (fallback a http://). + timeout_s: Timeout de la peticion en segundos. Default 15.0. + verify_tls: Si False, crea un ssl context sin verificacion (inseguro, + solo para recon de hosts propios con cert self-signed). Default True. + max_html_bytes: Corta el HTML leido a este tamano para no descargar + megas. Default 500_000 (500 KB). + user_agent: User-Agent a enviar. Default un UA realista de Chrome. + + Returns: + dict. En exito:: + + { + "status": "ok", + "url": <url solicitada>, + "final_url": <url tras redirects>, + "status_code": int, + "headers": {clave_lower: valor_str, ...}, # ultimo valor si repetido + "cookies": [<nombre_cookie>, ...], # SOLO nombres, nunca valores + "title": str | None, + "server": str | None, # atajo a headers["server"] + "html": str, # cortado a max_html_bytes + "html_len": int, + "raw": str, # status + headers (sin html) + } + + En error de red total (host no resuelve / conexion rechazada / timeout):: + + {"status": "error", "error": "<mensaje>", "url": <url>} + + SEGURIDAD: `cookies` lleva SOLO los nombres de las cookies de Set-Cookie, + jamas los valores (que contienen tokens de sesion). + """ + if not url or not url.strip(): + return {"status": "error", "error": "fetch_http_fingerprint: url vacia", "url": url} + + url = url.strip() + ua = user_agent or _DEFAULT_UA + + # Construye la lista de URLs a intentar: si no hay esquema, https:// y luego + # http:// como fallback. Si ya trae esquema, solo esa. + if "://" in url: + candidates = [url] + else: + candidates = ["https://" + url, "http://" + url] + + last_error: str | None = None + for candidate in candidates: + try: + return _do_get(candidate, timeout_s, verify_tls, max_html_bytes, ua) + except urllib.error.URLError as e: + reason = getattr(e, "reason", e) + last_error = f"{candidate}: {reason}" + except socket.timeout: + last_error = f"{candidate}: timeout tras {timeout_s}s" + except ssl.SSLError as e: + last_error = f"{candidate}: SSL error: {e}" + except (OSError, ValueError) as e: # conexion rechazada, URL invalida, etc. + last_error = f"{candidate}: {e}" + + return { + "status": "error", + "error": f"fetch_http_fingerprint: {last_error or 'fallo desconocido'}", + "url": url, + } + + +if __name__ == "__main__": + # Smoke test contra un sitio publico, best-effort (no rompe si no hay red). + res = fetch_http_fingerprint("https://example.com") + print("status:", res["status"]) + if res["status"] == "ok": + print(" final_url:", res["final_url"]) + print(" status_code:", res["status_code"]) + print(" server:", res["server"]) + print(" title:", res["title"]) + print(" cookies:", res["cookies"]) + print(" html_len:", res["html_len"]) + else: + print(" (red no disponible, tolerado):", res["error"]) diff --git a/python/functions/cybersecurity/fetch_http_fingerprint_test.py b/python/functions/cybersecurity/fetch_http_fingerprint_test.py new file mode 100644 index 00000000..87d1ef2d --- /dev/null +++ b/python/functions/cybersecurity/fetch_http_fingerprint_test.py @@ -0,0 +1,108 @@ +"""Tests para fetch_http_fingerprint. + +Levanta un http.server.HTTPServer local en 127.0.0.1 en un puerto efimero, +servido por un thread, con headers fake (Server, X-Powered-By, Set-Cookie) y +un HTML con <title>Hola. NO toca red externa. +""" + +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +from fetch_http_fingerprint import fetch_http_fingerprint + +_HTML = b"Holaok" + + +class _FakeHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 (firma de BaseHTTPRequestHandler) + self.send_response(200) + self.send_header("Server", "TestServer/1.0") + self.send_header("X-Powered-By", "PHP/8.1") + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Set-Cookie", "PHPSESSID=secret_value_no_capturar; Path=/") + self.end_headers() + self.wfile.write(_HTML) + + def log_message(self, *args): # silencia el logging del server en los tests + pass + + +def _start_server(): + server = HTTPServer(("127.0.0.1", 0), _FakeHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +def test_status_ok_y_status_code_200(): + server, thread = _start_server() + try: + port = server.server_address[1] + res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/") + assert res["status"] == "ok", res + assert res["status_code"] == 200, res["status_code"] + finally: + server.shutdown() + thread.join(timeout=2) + + +def test_headers_normalizados_lowercase(): + server, thread = _start_server() + try: + port = server.server_address[1] + res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/") + assert res["headers"]["server"] == "TestServer/1.0", res["headers"] + assert res["server"] == "TestServer/1.0", res["server"] + assert res["headers"]["x-powered-by"] == "PHP/8.1", res["headers"] + finally: + server.shutdown() + thread.join(timeout=2) + + +def test_cookies_solo_nombres_no_valores(): + server, thread = _start_server() + try: + port = server.server_address[1] + res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/") + assert "PHPSESSID" in res["cookies"], res["cookies"] + # El valor sensible NUNCA debe aparecer en la salida. + assert "secret_value_no_capturar" not in res["raw"], "valor de cookie filtrado en raw" + assert all("=" not in c for c in res["cookies"]), res["cookies"] + finally: + server.shutdown() + thread.join(timeout=2) + + +def test_title_extraido(): + server, thread = _start_server() + try: + port = server.server_address[1] + res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/") + assert res["title"] == "Hola", res["title"] + assert res["html_len"] == len(_HTML), res["html_len"] + finally: + server.shutdown() + thread.join(timeout=2) + + +def test_url_vacia_devuelve_error(): + res = fetch_http_fingerprint("") + assert res["status"] == "error", res + assert "url vacia" in res["error"], res["error"] + + +def test_host_inexistente_devuelve_error_sin_lanzar(): + # Puerto cerrado en loopback: conexion rechazada, debe devolver error, no lanzar. + res = fetch_http_fingerprint("http://127.0.0.1:1/") + assert res["status"] == "error", res + assert res["url"] == "http://127.0.0.1:1/", res + + +if __name__ == "__main__": + test_status_ok_y_status_code_200() + test_headers_normalizados_lowercase() + test_cookies_solo_nombres_no_valores() + test_title_extraido() + test_url_vacia_devuelve_error() + test_host_inexistente_devuelve_error_sin_lanzar() + print("all tests passed") diff --git a/python/functions/cybersecurity/grab_service_banner.md b/python/functions/cybersecurity/grab_service_banner.md new file mode 100644 index 00000000..1ddefda3 --- /dev/null +++ b/python/functions/cybersecurity/grab_service_banner.md @@ -0,0 +1,93 @@ +--- +name: grab_service_banner +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: impure +signature: "def grab_service_banner(host: str, port: int, timeout_s: float = 3.0, send_probe: bool = True) -> dict" +description: "Captura el banner de un servicio TCP y lo identifica heuristicamente sin nmap -sV. Abre un socket TCP a host:port, opcionalmente envia un probe (HEAD / HTTP/1.0 para puertos web), lee hasta ~4096 bytes con timeout y reconoce el servicio (ssh, ftp, smtp, http, mysql/mariadb, redis, pop3, imap, telnet, ...) por heuristica sobre el banner, extrayendo producto y version best-effort. Complementa a un port scan: el scan dice si el puerto esta abierto, esta funcion dice QUE servicio y version hablan detras. Solo stdlib (socket, re, struct). NO lanza: devuelve dict status ok/error con campo raw (repr del banner crudo)." +tags: [recon, cybersecurity, banner-grab, service-detection, network] +params: + - name: host + desc: "Hostname o IP del objetivo (ej. 'scanme.nmap.org', '127.0.0.1'). Vacio devuelve status error." + - name: port + desc: "Puerto TCP a sondear (ej. 22 ssh, 80 http, 3306 mysql, 6379 redis). Fuera del rango 1..65535 o no convertible a int devuelve status error." + - name: timeout_s + desc: "Timeout en segundos tanto de conexion como de lectura del socket. Default 3.0. Subirlo para hosts lentos; bajarlo para barridos rapidos de muchos puertos." + - name: send_probe + desc: "Si True (default) y el puerto esta en el mapa interno de probes (puertos HTTP tipicos: 80/8080/8000/8888/8081/8008), envia 'HEAD / HTTP/1.0\\r\\n\\r\\n' para provocar respuesta de servicios web que no emiten banner pasivo. Para el resto de puertos no envia nada e intenta leer el banner pasivo (SSH/FTP/SMTP/POP3/IMAP emiten banner solo con conectar). False nunca envia probe (captura siempre pasiva)." +output: "dict de estado. ok: {status:'ok', host, port:int, service:str (ssh|ftp|smtp|http|mysql|redis|pop3|imap|telnet|ftp-or-smtp|unknown), product:str (best-effort, p.ej. OpenSSH/nginx/Postfix/MySQL; '' si no se extrae), version:str (best-effort, p.ej. '8.9p1'; '' si no se extrae), banner:str (banner decodificado y .strip()), raw:str (repr() del banner crudo en bytes, seguro para guardar)}. error: {status:'error', error:str, host, port}. Nunca lanza excepciones." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_identifica_ssh_de_banner_local", "test_host_vacio_devuelve_error", "test_port_fuera_de_rango_devuelve_error"] +test_file_path: "python/functions/cybersecurity/grab_service_banner_test.py" +file_path: "python/functions/cybersecurity/grab_service_banner.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity import grab_service_banner + +# 1) Identificar el servicio SSH del host oficial de pruebas de nmap (legal). +res = grab_service_banner("scanme.nmap.org", 22, timeout_s=5) +if res["status"] == "ok": + print(res["service"]) # "ssh" + print(res["product"]) # "OpenSSH" + print(res["version"]) # "8.9p1" (o similar) + print(res["banner"]) # "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1" +else: + print("error:", res["error"]) + +# 2) Identificar un servidor web: el probe HTTP provoca respuesta con Server:. +res = grab_service_banner("scanme.nmap.org", 80, timeout_s=5) +print(res["service"], res["product"], res["version"]) # http nginx 1.18.0 +``` + +Tambien via `fn run` tras indexar: + +```bash +./fn run grab_service_banner_py_cybersecurity +``` + +(El smoke del modulo sondea scanme.nmap.org:22 y tolera fallos de red.) + +## Cuando usarla + +Usala cuando YA sabes que un puerto esta abierto (p.ej. tras un escaneo de +puertos) y quieres identificar el servicio y su version de forma rapida para un +puerto concreto, sin levantar `nmap -sV`. Encaja como segundo paso de recon: +primero localizas los puertos abiertos de un host, luego compones esta funcion +sobre cada puerto interesante para etiquetar QUE habla detras (ssh, http, mysql, +redis, ...) y guardar el banner como evidencia en la nota OSINT. + +## Gotchas + +- Funcion impura: abre una conexion TCP real al objetivo. Solo sondea hosts + propios o con autorizacion explicita; conectar a servicios de terceros sin + permiso puede ser ilegal. +- TLS/HTTPS implicito (443, 993, 995, 465, ...): el servicio espera un handshake + TLS antes de hablar, asi que el banner plano que captura esta funcion NO + funciona ahi — devolvera bytes binarios ilegibles o timeout, con + `service:"unknown"`. Para esos puertos hay que envolver el socket en TLS + (ssl.SSLSocket) primero; esta funcion no lo hace. +- Banner pasivo no garantizado: algunos servicios no emiten nada hasta completar + un handshake especifico del protocolo. Para esos casos `banner` puede venir + vacio y `service:"unknown"` aunque el puerto este abierto. El probe HTTP solo + cubre los puertos web listados en el mapa interno; otros protocolos quedarian + sin probe activo. +- Decodificacion best-effort: el banner se decodifica utf-8 y cae a latin-1, lo + que puede dar mojibake en bytes no textuales (handshakes binarios como MySQL). + Por eso `raw` guarda el `repr()` de los bytes crudos como fuente fiable. +- La identificacion es heuristica (regex/substring): puede equivocarse o quedar + como `service:"unknown"`. `product`/`version` son best-effort y pueden ser "". +- Nunca lanza: revisa siempre `res["status"]` antes de leer `service`/`banner`. + Puerto cerrado/filtrado/inalcanzable devuelve `status:"error"`. diff --git a/python/functions/cybersecurity/grab_service_banner.py b/python/functions/cybersecurity/grab_service_banner.py new file mode 100644 index 00000000..debcf2a3 --- /dev/null +++ b/python/functions/cybersecurity/grab_service_banner.py @@ -0,0 +1,334 @@ +"""Captura e identificacion heuristica del banner de un servicio TCP. + +Funcion IMPURA: abre un socket TCP a host:port, opcionalmente envia un probe +(por ejemplo `HEAD / HTTP/1.0` para puertos HTTP), lee el banner inicial que +emite el servicio y lo identifica heuristicamente (ssh, ftp, smtp, http, mysql, +redis, telnet, pop3, imap, ...). Solo usa la stdlib (`socket`, `re`, `struct`). + +Complementa a un escaneo de puertos: mientras un port scan solo dice si el +puerto esta abierto, esta funcion dice QUE servicio (y a menudo que producto y +version) habla detras del puerto, sin depender de `nmap -sV`. + +NO lanza excepciones: devuelve SIEMPRE un dict con `status` "ok" o "error" y un +campo `raw` con el banner crudo en forma segura (repr). Solo conectar a hosts +propios o con autorizacion explicita. +""" + +import re +import socket +import struct + +# Probes activos por puerto bien conocido. Si el puerto esta aqui y +# send_probe=True, se envia el probe tras conectar para provocar respuesta de +# servicios que no emiten banner pasivo (HTTP es el caso tipico). El resto de +# servicios (SSH/FTP/SMTP/POP3/IMAP) suelen emitir banner solo con conectar, asi +# que para ellos no se envia nada. +_HTTP_PORTS = (80, 8080, 8000, 8888, 8081, 8008) +_HTTP_PROBE = b"HEAD / HTTP/1.0\r\n\r\n" + +# Mapa de probes por puerto. Permite anadir probes especificos por puerto. +_PROBES: dict[int, bytes] = {p: _HTTP_PROBE for p in _HTTP_PORTS} + + +def _decode_best_effort(data: bytes) -> str: + """Decodifica bytes a str probando utf-8 y cayendo a latin-1 (nunca falla).""" + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + # latin-1 mapea todos los bytes 0-255: nunca lanza, puede dar mojibake. + return data.decode("latin-1", errors="replace") + + +def _parse_http(text: str) -> tuple[str, str]: + """Extrae (product, version) de una respuesta HTTP best-effort. + + Lee la cabecera `Server:` si esta presente (ej. "Server: nginx/1.18.0"). + """ + m = re.search(r"^Server:\s*(.+)$", text, re.IGNORECASE | re.MULTILINE) + if not m: + return "", "" + server = m.group(1).strip() + # "nginx/1.18.0 (Ubuntu)" -> product "nginx", version "1.18.0". + vm = re.match(r"([^/\s]+)/([^\s;]+)", server) + if vm: + return vm.group(1), vm.group(2) + return server, "" + + +def _parse_ssh(text: str) -> tuple[str, str]: + """Extrae (product, version) de un banner SSH (ej. SSH-2.0-OpenSSH_8.9p1).""" + m = re.search(r"SSH-[\d.]+-([A-Za-z0-9_.+-]+)", text) + if not m: + return "", "" + impl = m.group(1) + # "OpenSSH_8.9p1" -> product "OpenSSH", version "8.9p1". + vm = re.match(r"([A-Za-z]+)[_/-]([\d][\w.+-]*)", impl) + if vm: + return vm.group(1), vm.group(2) + return impl, "" + + +def _parse_ftp(text: str) -> tuple[str, str]: + """Extrae (product, version) de un banner FTP (ej. 220 vsFTPd 3.0.3).""" + for product, rx in ( + ("vsFTPd", r"vsFTPd\s+([\d][\w.]*)"), + ("ProFTPD", r"ProFTPD\s+([\d][\w.]*)"), + ("Pure-FTPd", r"Pure-FTPd"), + ("FileZilla", r"FileZilla\s+Server\s*(?:version\s*)?([\d][\w.]*)?"), + ): + m = re.search(rx, text, re.IGNORECASE) + if m: + try: + ver = m.group(1) or "" + except IndexError: + ver = "" + return product, ver or "" + return "", "" + + +def _parse_smtp(text: str) -> tuple[str, str]: + """Extrae (product, version) de un banner SMTP (ej. 220 mail ESMTP Postfix).""" + for product, rx in ( + ("Postfix", r"Postfix"), + ("Exim", r"Exim\s+([\d][\w.]*)"), + ("Sendmail", r"Sendmail\s+([\d][\w.+/]*)"), + ("Microsoft ESMTP", r"Microsoft\s+ESMTP"), + ): + m = re.search(rx, text, re.IGNORECASE) + if m: + try: + ver = m.group(1) or "" + except IndexError: + ver = "" + return product, ver or "" + return "", "" + + +def _parse_mysql(data: bytes) -> tuple[str, str]: + """Extrae la version del server MySQL/MariaDB del handshake binario. + + El primer paquete del protocolo MySQL es: + [3 bytes length][1 byte seq][1 byte protocol version][server version NUL-terminated]... + """ + if len(data) < 6: + return "", "" + try: + # struct: longitud (3 bytes little-endian) + seq (1 byte). + proto_ver = data[4] + if proto_ver != 10: # protocolo handshake v10 (el comun) + return "", "" + # La version del server empieza en el byte 5 y termina en NUL. + end = data.index(b"\x00", 5) + version = data[5:end].decode("latin-1", errors="replace") + product = "MariaDB" if "mariadb" in version.lower() else "MySQL" + # Limpia version a algo tipo "8.0.32" / "10.6.12-MariaDB". + vm = re.match(r"([\d][\w.+-]*)", version) + return product, (vm.group(1) if vm else version) + except (ValueError, IndexError, struct.error): + return "", "" + + +def _identify(text: str, raw_bytes: bytes) -> tuple[str, str, str]: + """Identifica (service, product, version) a partir del banner. + + Heuristica por substring/regex sobre el texto decodificado y, para MySQL, + sobre los bytes crudos del handshake binario. + """ + # SSH: banner empieza por "SSH-". + if text.startswith("SSH-") or "SSH-2.0" in text or "SSH-1." in text: + product, version = _parse_ssh(text) + return "ssh", product or "SSH", version + + # HTTP: linea de estado "HTTP/x.y NNN". + if re.match(r"HTTP/\d", text) or "\nHTTP/" in text: + product, version = _parse_http(text) + return "http", product, version + + # MySQL/MariaDB: handshake binario (protocolo 10). Detectar por bytes. + if len(raw_bytes) >= 6 and raw_bytes[4] == 10: + product, version = _parse_mysql(raw_bytes) + if product: + return "mysql", product, version + + # Redis: responde a comandos con "-ERR"/"+OK"/"+PONG"; INFO empieza "# Server". + if text.startswith(("-ERR", "+PONG", "+OK", "# Server")) or "redis_version" in text: + vm = re.search(r"redis_version:([\d][\w.]*)", text) + return "redis", "Redis", (vm.group(1) if vm else "") + + # FTP: respuesta de bienvenida "220 ..." con marcas FTP conocidas. + if text.startswith("220") and re.search(r"ftp|vsftpd|proftpd|pure-ftpd|filezilla", text, re.IGNORECASE): + product, version = _parse_ftp(text) + return "ftp", product, version + + # SMTP: "220 ..." con "SMTP"/"ESMTP". + if text.startswith("220") and re.search(r"e?smtp", text, re.IGNORECASE): + product, version = _parse_smtp(text) + return "smtp", product, version + + # POP3: respuesta de bienvenida "+OK ...". + if text.startswith("+OK"): + return "pop3", "", "" + + # IMAP: respuesta de bienvenida "* OK ...". + if text.startswith("* OK") or "IMAP" in text.upper()[:40]: + return "imap", "", "" + + # Generico "220 " sin marca clara -> probablemente FTP/SMTP sin identificar. + if text.startswith("220"): + return "ftp-or-smtp", "", "" + + # Telnet: a menudo negocia con bytes IAC (0xFF) al conectar. + if raw_bytes.startswith(b"\xff"): + return "telnet", "", "" + + return "unknown", "", "" + + +def grab_service_banner( + host: str, + port: int, + timeout_s: float = 3.0, + send_probe: bool = True, +) -> dict: + """Conecta por TCP a host:port, lee el banner del servicio y lo identifica. + + Abre un socket TCP, opcionalmente envia un probe (HTTP para puertos web), + lee hasta ~4096 bytes con timeout, decodifica best-effort e identifica el + servicio por heuristica (ssh, ftp, smtp, http, mysql, redis, pop3, imap, + telnet, ...). Extrae producto y version cuando es posible. + + Args: + host: Hostname o IP del objetivo (ej. "scanme.nmap.org", "127.0.0.1"). + Vacio devuelve status error. + port: Puerto TCP (ej. 22, 80, 3306). Fuera de 1..65535 devuelve error. + timeout_s: Timeout de conexion y de lectura en segundos. Default 3.0. + send_probe: Si True y el puerto esta en el mapa interno de probes (los + puertos HTTP tipicos: 80/8080/8000/8888/...), envia el probe HTTP + `HEAD / HTTP/1.0` para provocar respuesta. Para el resto de puertos + no envia nada e intenta leer el banner pasivo (SSH/FTP/SMTP/POP3/IMAP + emiten banner al conectar). Si False, nunca envia probe. + + Returns: + Dict de estado. Nunca lanza. + ok: {"status":"ok", "host", "port":int, "service":str, "product":str, + "version":str, "banner":str (banner limpio), "raw":str (repr seguro + del banner crudo)} + error: {"status":"error", "error":str, "host", "port":int} + """ + if not host or not host.strip(): + return {"status": "error", "error": "grab_service_banner: host vacio", "host": host, "port": port} + + try: + port = int(port) + except (TypeError, ValueError): + return { + "status": "error", + "error": f"grab_service_banner: port invalido: {port!r}", + "host": host, + "port": port, + } + + if not (1 <= port <= 65535): + return { + "status": "error", + "error": f"grab_service_banner: port fuera de rango 1..65535: {port}", + "host": host, + "port": port, + } + + host = host.strip() + sock = None + try: + sock = socket.create_connection((host, port), timeout=timeout_s) + sock.settimeout(timeout_s) + + # Probe activo solo si procede (puerto HTTP) y send_probe=True. + if send_probe and port in _PROBES: + try: + sock.sendall(_PROBES[port]) + except OSError: + pass # algunos servicios cierran ante un probe inesperado + + chunks: list[bytes] = [] + total = 0 + try: + while total < 4096: + data = sock.recv(4096 - total) + if not data: + break + chunks.append(data) + total += len(data) + # La mayoria de banners caben en un recv; si llega un salto de + # linea de fin de banner, paramos para no bloquear en el timeout. + if b"\n" in data and port not in _PROBES: + break + except socket.timeout: + pass # timeout de lectura: usamos lo recibido hasta ahora + + raw_bytes = b"".join(chunks) + except socket.timeout: + return { + "status": "error", + "error": f"grab_service_banner: timeout conectando a {host}:{port} ({timeout_s}s)", + "host": host, + "port": port, + } + except ConnectionRefusedError: + return { + "status": "error", + "error": f"grab_service_banner: connection refused {host}:{port}", + "host": host, + "port": port, + } + except socket.gaierror as e: + return { + "status": "error", + "error": f"grab_service_banner: no se pudo resolver host '{host}': {e}", + "host": host, + "port": port, + } + except OSError as e: + return { + "status": "error", + "error": f"grab_service_banner: error de socket {host}:{port}: {e}", + "host": host, + "port": port, + } + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass + + text = _decode_best_effort(raw_bytes) + service, product, version = _identify(text, raw_bytes) + banner = text.strip() + + return { + "status": "ok", + "host": host, + "port": port, + "service": service, + "product": product, + "version": version, + "banner": banner, + "raw": repr(raw_bytes), + } + + +if __name__ == "__main__": + # Smoke: intenta capturar el banner SSH del host oficial de pruebas de nmap. + # Tolera cualquier fallo de red sin romper (exit 0 siempre). + try: + result = grab_service_banner("scanme.nmap.org", 22, timeout_s=5) + print(result["status"]) + if result["status"] == "ok": + print(f"service={result['service']} product={result['product']} version={result['version']}") + print(f"banner: {result['banner']}") + else: + print("error tolerado:", result.get("error")) + except Exception as exc: # noqa: BLE001 - smoke nunca debe romper + print("smoke fallo (tolerado):", exc) diff --git a/python/functions/cybersecurity/grab_service_banner_test.py b/python/functions/cybersecurity/grab_service_banner_test.py new file mode 100644 index 00000000..a7d90f6e --- /dev/null +++ b/python/functions/cybersecurity/grab_service_banner_test.py @@ -0,0 +1,57 @@ +"""Tests para grab_service_banner (sin red externa; servidor TCP local fake).""" + +import os +import socket +import socketserver +import sys +import threading + +sys.path.insert(0, os.path.dirname(__file__)) + +from grab_service_banner import grab_service_banner + + +class _BannerHandler(socketserver.BaseRequestHandler): + """Emite un banner SSH fake al conectar, como hace un servidor SSH real.""" + + def handle(self): + try: + self.request.sendall(b"SSH-2.0-TestServer\r\n") + except OSError: + pass + + +def test_identifica_ssh_de_banner_local(): + """Un servidor TCP local que emite 'SSH-2.0-...' se identifica como ssh.""" + server = socketserver.TCPServer(("127.0.0.1", 0), _BannerHandler) + # bind_and_activate por defecto ya hizo bind; tomamos el puerto efimero. + host, port = server.server_address + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + result = grab_service_banner(host, port, timeout_s=2.0, send_probe=False) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2.0) + + assert result["status"] == "ok" + assert result["service"] == "ssh" + assert "SSH-2.0-TestServer" in result["banner"] + assert result["product"] == "TestServer" or result["product"] # best-effort + + +def test_host_vacio_devuelve_error(): + """Un host vacio devuelve status error sin lanzar y sin tocar la red.""" + result = grab_service_banner("", 22) + assert result["status"] == "error" + assert "vacio" in result["error"] + assert set(["status", "error", "host", "port"]).issubset(result.keys()) + + +def test_port_fuera_de_rango_devuelve_error(): + """Un puerto fuera del rango 1..65535 devuelve status error sin conectar.""" + result = grab_service_banner("127.0.0.1", 70000) + assert result["status"] == "error" + assert "rango" in result["error"] + assert result["port"] == 70000 diff --git a/python/functions/cybersecurity/identify_port_service.md b/python/functions/cybersecurity/identify_port_service.md new file mode 100644 index 00000000..0846be32 --- /dev/null +++ b/python/functions/cybersecurity/identify_port_service.md @@ -0,0 +1,64 @@ +--- +name: identify_port_service +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: pure +signature: "def identify_port_service(port: int, proto: str = 'tcp') -> dict" +description: "Mapea un número de puerto (TCP/UDP) a su servicio IANA well-known más una descripción corta, usando una tabla estática embebida (~120 puertos comunes en pentest/OSINT). Función pura, sin red ni I/O: dado un puerto abierto detectado por un scanner, dice qué servicio se ESPERA típicamente ahí por convención (ssh en 22, https en 443, mysql en 3306, postgresql en 5432, rdp en 3389, redis en 6379, mongodb en 27017, etc.), no lo verifica en vivo. Complementa a scan_tcp_ports/scan_port_tcp y grab_service_banner para enriquecer informes de reconocimiento de red." +tags: [recon, cybersecurity, port-service, port, service, iana, well-known, ports, network, osint] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: true +tests: ["test_port_22_es_ssh", "test_port_443_es_https", "test_port_3306_es_mysql", "test_port_53_udp_es_dns", "test_puerto_fuera_de_rango_es_invalid", "test_puerto_desconocido_known_false", "test_proto_invalido_es_invalid", "test_proto_se_normaliza_mayusculas", "test_determinismo_misma_entrada_misma_salida"] +test_file_path: "python/functions/cybersecurity/identify_port_service_test.py" +file_path: "python/functions/cybersecurity/identify_port_service.py" +params: + - name: port + desc: "Número de puerto a identificar, rango válido 0-65535. Fuera de rango devuelve service 'invalid'." + - name: proto + desc: "Protocolo de transporte: 'tcp' o 'udp' (default 'tcp'). Se normaliza a minúsculas; cualquier otro valor devuelve service 'invalid'." +output: "dict con {port: int, proto: str, service: str, description: str, known: bool}. service='ssh'/'https'/... y known=True si hay match en la tabla; service='unknown', description='', known=False si el puerto/proto es válido pero no está catalogado; service='invalid' si el puerto está fuera de rango 0-65535 o el protocolo no es tcp/udp." +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity.identify_port_service import identify_port_service + +identify_port_service(22) +# -> {"port": 22, "proto": "tcp", "service": "ssh", +# "description": "Secure Shell", "known": True} + +identify_port_service(53, "udp") +# -> {"port": 53, "proto": "udp", "service": "dns", +# "description": "Domain Name System", "known": True} + +identify_port_service(99999) +# -> {"port": 99999, "proto": "tcp", "service": "invalid", +# "description": "", "known": False} +``` + +Invocación directa via `fn run`: + +```bash +./fn run identify_port_service_py_cybersecurity +# imprime el JSON de varios puertos de muestra (22, 443, 53/udp, 3306, 99999) +``` + +## Cuando usarla + +Cuando tienes un puerto abierto (por ejemplo de `scan_tcp_ports` / `scan_port_tcp_go_cybersecurity` o de un parseo de salida de `nmap_scan`) y quieres el servicio esperado por convención IANA **sin sondear en vivo** — para etiquetar resultados, generar resúmenes legibles o enriquecer informes de reconocimiento (recon/OSINT) de forma determinista y offline. + +## Gotchas + +- Devuelve el servicio **convencional** según IANA/nmap-services, **no verifica** que sea el que realmente corre en ese puerto. Un servicio puede escuchar en un puerto no estándar (p. ej. SSH en 2222, o un panel admin en 8443). Para confirmar el servicio real, sondea con `grab_service_banner` / `scan_port_tcp_go_cybersecurity` (que devuelve banner) o `nmap_scan` con detección de versión. +- La tabla cubre los puertos más comunes en pentest/OSINT, no es exhaustiva (no incluye todos los registros IANA). Un puerto válido pero no catalogado devuelve `service: "unknown"`, `known: False` — no es un error. +- `service: "invalid"` (puerto fuera de 0-65535 o proto distinto de tcp/udp) se distingue de `service: "unknown"` (puerto/proto válidos pero no en la tabla). Ambos tienen `known: False`. diff --git a/python/functions/cybersecurity/identify_port_service.py b/python/functions/cybersecurity/identify_port_service.py new file mode 100644 index 00000000..0ec56485 --- /dev/null +++ b/python/functions/cybersecurity/identify_port_service.py @@ -0,0 +1,206 @@ +"""Mapeo puro de número de puerto a servicio IANA well-known. + +Tabla estática embebida que asocia (puerto, protocolo) con el servicio que la +convención IANA / nmap-services espera típicamente en ese puerto, más una +descripción corta. Sin red, sin I/O: dado un puerto abierto detectado por un +scanner, esta función dice qué servicio se ESPERA ahí por convención, no lo +verifica en vivo. +""" + +# (port, proto) -> (service, description) +WELL_KNOWN: dict[tuple[int, str], tuple[str, str]] = { + (20, "tcp"): ("ftp-data", "FTP Data Transfer"), + (21, "tcp"): ("ftp", "File Transfer Protocol (control)"), + (22, "tcp"): ("ssh", "Secure Shell"), + (23, "tcp"): ("telnet", "Telnet"), + (25, "tcp"): ("smtp", "Simple Mail Transfer Protocol"), + (37, "tcp"): ("time", "Time Protocol"), + (43, "tcp"): ("whois", "WHOIS Directory Service"), + (53, "tcp"): ("dns", "Domain Name System (zone transfer)"), + (53, "udp"): ("dns", "Domain Name System"), + (67, "udp"): ("dhcp", "DHCP Server (BOOTP)"), + (68, "udp"): ("dhcp", "DHCP Client (BOOTP)"), + (69, "udp"): ("tftp", "Trivial File Transfer Protocol"), + (79, "tcp"): ("finger", "Finger Protocol"), + (80, "tcp"): ("http", "Hypertext Transfer Protocol"), + (88, "tcp"): ("kerberos", "Kerberos Authentication"), + (110, "tcp"): ("pop3", "Post Office Protocol v3"), + (111, "tcp"): ("rpcbind", "ONC RPC / Portmapper"), + (111, "udp"): ("rpcbind", "ONC RPC / Portmapper"), + (113, "tcp"): ("ident", "Ident / Auth Service"), + (119, "tcp"): ("nntp", "Network News Transfer Protocol"), + (123, "udp"): ("ntp", "Network Time Protocol"), + (135, "tcp"): ("msrpc", "Microsoft RPC Endpoint Mapper"), + (137, "udp"): ("netbios-ns", "NetBIOS Name Service"), + (138, "udp"): ("netbios-dgm", "NetBIOS Datagram Service"), + (139, "tcp"): ("netbios-ssn", "NetBIOS Session Service"), + (143, "tcp"): ("imap", "Internet Message Access Protocol"), + (161, "udp"): ("snmp", "Simple Network Management Protocol"), + (162, "udp"): ("snmptrap", "SNMP Trap"), + (177, "udp"): ("xdmcp", "X Display Manager Control Protocol"), + (179, "tcp"): ("bgp", "Border Gateway Protocol"), + (389, "tcp"): ("ldap", "Lightweight Directory Access Protocol"), + (427, "tcp"): ("svrloc", "Service Location Protocol"), + (443, "tcp"): ("https", "HTTP over TLS/SSL"), + (445, "tcp"): ("smb", "SMB / Microsoft Directory Services"), + (464, "tcp"): ("kpasswd", "Kerberos Password Change"), + (465, "tcp"): ("smtps", "SMTP over TLS/SSL"), + (500, "udp"): ("isakmp", "ISAKMP / IKE (IPsec)"), + (512, "tcp"): ("exec", "Remote Process Execution (rexec)"), + (513, "tcp"): ("login", "Remote Login (rlogin)"), + (514, "tcp"): ("shell", "Remote Shell (rsh)"), + (514, "udp"): ("syslog", "Syslog"), + (515, "tcp"): ("printer", "Line Printer Daemon (LPD)"), + (520, "udp"): ("rip", "Routing Information Protocol"), + (523, "tcp"): ("ibm-db2", "IBM DB2"), + (548, "tcp"): ("afp", "Apple Filing Protocol"), + (554, "tcp"): ("rtsp", "Real Time Streaming Protocol"), + (587, "tcp"): ("submission", "SMTP Mail Submission"), + (623, "udp"): ("ipmi", "IPMI / RMCP"), + (631, "tcp"): ("ipp", "Internet Printing Protocol"), + (636, "tcp"): ("ldaps", "LDAP over TLS/SSL"), + (873, "tcp"): ("rsync", "rsync File Synchronization"), + (902, "tcp"): ("vmware", "VMware ESXi / Authentication"), + (989, "tcp"): ("ftps-data", "FTP Data over TLS/SSL"), + (990, "tcp"): ("ftps", "FTP over TLS/SSL"), + (993, "tcp"): ("imaps", "IMAP over TLS/SSL"), + (995, "tcp"): ("pop3s", "POP3 over TLS/SSL"), + (1080, "tcp"): ("socks", "SOCKS Proxy"), + (1194, "udp"): ("openvpn", "OpenVPN"), + (1352, "tcp"): ("lotusnotes", "IBM Lotus Notes / Domino"), + (1433, "tcp"): ("mssql", "Microsoft SQL Server"), + (1434, "udp"): ("mssql-m", "Microsoft SQL Monitor"), + (1521, "tcp"): ("oracle", "Oracle Database Listener"), + (1723, "tcp"): ("pptp", "Point-to-Point Tunneling Protocol"), + (1883, "tcp"): ("mqtt", "MQTT Message Broker"), + (2049, "tcp"): ("nfs", "Network File System"), + (2082, "tcp"): ("cpanel", "cPanel"), + (2083, "tcp"): ("cpanel-ssl", "cPanel over TLS/SSL"), + (2181, "tcp"): ("zookeeper", "Apache ZooKeeper"), + (2375, "tcp"): ("docker", "Docker API (unencrypted)"), + (2376, "tcp"): ("docker-ssl", "Docker API over TLS"), + (2483, "tcp"): ("oracle-db", "Oracle DB (insecure)"), + (2484, "tcp"): ("oracle-db-ssl", "Oracle DB over TLS/SSL"), + (3000, "tcp"): ("dev-http", "Development HTTP / Grafana"), + (3128, "tcp"): ("squid", "Squid HTTP Proxy"), + (3268, "tcp"): ("globalcat", "LDAP Global Catalog"), + (3306, "tcp"): ("mysql", "MySQL / MariaDB"), + (3389, "tcp"): ("rdp", "Remote Desktop Protocol"), + (3690, "tcp"): ("svn", "Subversion"), + (4369, "tcp"): ("epmd", "Erlang Port Mapper Daemon"), + (4444, "tcp"): ("metasploit", "Metasploit Default Listener"), + (4505, "tcp"): ("saltstack", "SaltStack Publish"), + (4506, "tcp"): ("saltstack", "SaltStack Request"), + (5000, "tcp"): ("upnp", "UPnP / Flask Dev Server"), + (5060, "udp"): ("sip", "Session Initiation Protocol"), + (5061, "tcp"): ("sips", "SIP over TLS"), + (5432, "tcp"): ("postgresql", "PostgreSQL Database"), + (5601, "tcp"): ("kibana", "Kibana"), + (5672, "tcp"): ("amqp", "Advanced Message Queuing (RabbitMQ)"), + (5900, "tcp"): ("vnc", "Virtual Network Computing"), + (5984, "tcp"): ("couchdb", "Apache CouchDB"), + (5985, "tcp"): ("winrm", "Windows Remote Management (HTTP)"), + (5986, "tcp"): ("winrm-ssl", "Windows Remote Management (HTTPS)"), + (6379, "tcp"): ("redis", "Redis Key-Value Store"), + (6443, "tcp"): ("kubernetes", "Kubernetes API Server"), + (6660, "tcp"): ("irc", "Internet Relay Chat"), + (6667, "tcp"): ("irc", "Internet Relay Chat"), + (7001, "tcp"): ("weblogic", "Oracle WebLogic"), + (8000, "tcp"): ("http-alt", "HTTP Alternate / Dev Server"), + (8008, "tcp"): ("http-alt", "HTTP Alternate"), + (8080, "tcp"): ("http-proxy", "HTTP Proxy / Alternate"), + (8086, "tcp"): ("influxdb", "InfluxDB"), + (8088, "tcp"): ("http-alt", "HTTP Alternate / Hadoop"), + (8443, "tcp"): ("https-alt", "HTTPS Alternate"), + (8500, "tcp"): ("consul", "HashiCorp Consul"), + (8888, "tcp"): ("http-alt", "HTTP Alternate / Jupyter"), + (9000, "tcp"): ("http-alt", "HTTP Alternate / PHP-FPM / SonarQube"), + (9042, "tcp"): ("cassandra", "Apache Cassandra (CQL)"), + (9092, "tcp"): ("kafka", "Apache Kafka Broker"), + (9200, "tcp"): ("elasticsearch", "Elasticsearch HTTP"), + (9300, "tcp"): ("elasticsearch", "Elasticsearch Transport"), + (9418, "tcp"): ("git", "Git Protocol"), + (9999, "tcp"): ("http-alt", "HTTP Alternate / Admin"), + (10000, "tcp"): ("webmin", "Webmin Admin Panel"), + (11211, "tcp"): ("memcached", "Memcached"), + (15672, "tcp"): ("rabbitmq-mgmt", "RabbitMQ Management UI"), + (27017, "tcp"): ("mongodb", "MongoDB Database"), + (27018, "tcp"): ("mongodb", "MongoDB Shard"), + (50000, "tcp"): ("sap", "SAP / DB2 DRDA"), +} + + +def identify_port_service(port: int, proto: str = "tcp") -> dict: + """Identifica el servicio IANA well-known esperado en un puerto. + + Función pura: consulta una tabla estática embebida, sin red ni I/O. Indica + qué servicio se ESPERA por convención en ese puerto, no verifica que sea el + que realmente corre allí. + + Args: + port: número de puerto (0-65535). + proto: protocolo, "tcp" o "udp" (default "tcp"). Se normaliza a minúsculas. + + Returns: + dict con claves: + - port (int): el puerto consultado. + - proto (str): el protocolo normalizado. + - service (str): nombre del servicio; "unknown" si no está en la + tabla; "invalid" si el puerto está fuera de rango o el protocolo + no es tcp/udp. + - description (str): descripción corta; "" cuando no se conoce. + - known (bool): True solo si hay match en la tabla. + + Ejemplos de retorno: + identify_port_service(22) + -> {"port": 22, "proto": "tcp", "service": "ssh", + "description": "Secure Shell", "known": True} + identify_port_service(99999) + -> {"port": 99999, "proto": "tcp", "service": "invalid", + "description": "", "known": False} + """ + proto_norm = str(proto).strip().lower() + + if not isinstance(port, int) or isinstance(port, bool): + return { + "port": port, + "proto": proto_norm, + "service": "invalid", + "description": "", + "known": False, + } + + if port < 0 or port > 65535 or proto_norm not in ("tcp", "udp"): + return { + "port": port, + "proto": proto_norm, + "service": "invalid", + "description": "", + "known": False, + } + + match = WELL_KNOWN.get((port, proto_norm)) + if match is None: + return { + "port": port, + "proto": proto_norm, + "service": "unknown", + "description": "", + "known": False, + } + + service, description = match + return { + "port": port, + "proto": proto_norm, + "service": service, + "description": description, + "known": True, + } + + +if __name__ == "__main__": + import json + + for p, pr in [(22, "tcp"), (443, "tcp"), (53, "udp"), (3306, "tcp"), (99999, "tcp")]: + print(json.dumps(identify_port_service(p, pr))) diff --git a/python/functions/cybersecurity/identify_port_service_test.py b/python/functions/cybersecurity/identify_port_service_test.py new file mode 100644 index 00000000..0a24af62 --- /dev/null +++ b/python/functions/cybersecurity/identify_port_service_test.py @@ -0,0 +1,71 @@ +"""Tests para identify_port_service.""" + +from identify_port_service import identify_port_service + + +def test_port_22_es_ssh(): + result = identify_port_service(22) + assert result == { + "port": 22, + "proto": "tcp", + "service": "ssh", + "description": "Secure Shell", + "known": True, + } + + +def test_port_443_es_https(): + result = identify_port_service(443) + assert result["service"] == "https" + assert result["known"] is True + assert result["proto"] == "tcp" + + +def test_port_3306_es_mysql(): + result = identify_port_service(3306) + assert result["service"] == "mysql" + assert result["known"] is True + + +def test_port_53_udp_es_dns(): + result = identify_port_service(53, "udp") + assert result["service"] == "dns" + assert result["proto"] == "udp" + assert result["known"] is True + + +def test_puerto_fuera_de_rango_es_invalid(): + result = identify_port_service(99999) + assert result["service"] == "invalid" + assert result["known"] is False + assert result["description"] == "" + # negativo también + assert identify_port_service(-1)["service"] == "invalid" + + +def test_puerto_desconocido_known_false(): + # Puerto válido pero no catalogado en la tabla. + result = identify_port_service(40404) + assert result["service"] == "unknown" + assert result["known"] is False + assert result["description"] == "" + + +def test_proto_invalido_es_invalid(): + result = identify_port_service(80, "sctp") + assert result["service"] == "invalid" + assert result["known"] is False + + +def test_proto_se_normaliza_mayusculas(): + result = identify_port_service(22, "TCP") + assert result["proto"] == "tcp" + assert result["service"] == "ssh" + assert result["known"] is True + + +def test_determinismo_misma_entrada_misma_salida(): + a = identify_port_service(8080, "tcp") + b = identify_port_service(8080, "tcp") + assert a == b + assert a["service"] == "http-proxy" diff --git a/python/functions/cybersecurity/nmap_scan.md b/python/functions/cybersecurity/nmap_scan.md new file mode 100644 index 00000000..f4e14b7e --- /dev/null +++ b/python/functions/cybersecurity/nmap_scan.md @@ -0,0 +1,131 @@ +--- +name: nmap_scan +kind: function +lang: py +domain: cybersecurity +version: "1.1.0" +purity: impure +signature: "def nmap_scan(target: str, profile: str = 'quick', ports: str | None = None, extra_args: list[str] | None = None, out_dir: str | None = None, timeout_s: int = 1800, confirm: bool = False, allowlist: list[str] | None = None) -> dict" +description: "Wrapper de `nmap` por perfiles para reconocimiento de red. Ejecuta nmap como subprocess forzando salida XML (-oX), la parsea con ElementTree y devuelve puertos abiertos y hosts vivos de forma estructurada. Funcion estrella de recon: corre en primer plano (quick, top1000, service) y segundo plano para scans largos (full-tcp, vuln, udp-top). NO lanza: devuelve dict status ok/error. Sin sudo por defecto (connect-scan TCP)." +tags: [recon, nmap, portscan, cybersecurity] +params: + - name: target + desc: "Host, IP o rango CIDR a escanear (ej. 'scanme.nmap.org', '192.168.1.10', o '192.168.1.0/24' con el perfil discovery). Vacio devuelve status error." + - name: profile + desc: "Clave de PROFILES que determina los flags de nmap. quick=(-T4 -F) top 100 puertos rapido; top1000=(-T4) los 1000 puertos default; full-tcp=(-p- -T4) los 65535 TCP, LARGO; service=(-sV -sC -T4) deteccion de version + scripts default; udp-top=(-sU --top-ports 100 -T4) UDP top 100, LARGO y suele requerir sudo; vuln=(-sV --script vuln -T4) scripts de vulnerabilidades, LARGO; discovery=(-sn) ping sweep / host discovery de una subred; aggressive=(-A -T4) OS+version+script+traceroute (el -O interno puede pedir sudo); os=(-O) OS detection, REQUIERE sudo/root. Perfil invalido devuelve status error listando los validos." + - name: ports + desc: "Especificacion de puertos para -p (ej. '22,80,443' o '1-1000'). Si se pasa, anade '-p ' al comando. None deja los puertos que defina el perfil." + - name: extra_args + desc: "Lista de flags adicionales de nmap a anadir tal cual al comando (ej. ['--open', '-Pn']). None no anade nada." + - name: out_dir + desc: "Directorio donde guardar el XML. Si se pasa, se crea y el XML se guarda como nmap---.xml (util para scans largos en background y conservar el resultado). None usa un archivo temporal." + - name: timeout_s + desc: "Segundos maximos de ejecucion del subprocess. Default 1800 (30 min). Para scans largos (full-tcp, vuln, udp-top) subir este valor; superarlo devuelve status error con mensaje claro." + - name: confirm + desc: "Confirmacion explicita para escanear un target publico o desconocido. Default False: si el target no es claramente privado/local (10.x, 192.168.x, 127.x, localhost, *.local/.lan/.internal/.home/.corp) y no esta en allowlist, el escaneo se rechaza con status error y needs_confirm=True (proteccion anti-escaneo no autorizado). Pasar True solo con autorizacion. No hace DNS lookup (sin red)." + - name: allowlist + desc: "Lista de targets autorizados. Un target pasa el guard sin confirm si coincide exactamente con una entrada o termina en ella (ej. ['scanme.nmap.org'] o ['example.com']). None o lista vacia no autoriza nada." +output: "dict. ok: {status:'ok', target, profile, command (cmd ejecutado), open_ports:[{port:int,proto,state,service,product,version}] (solo open/open|filtered), hosts_up:[ips] (host discovery), host_status, xml_path (siempre presente), raw (stdout de nmap, siempre presente), elapsed_s:float, started (ISO)}. error: {status:'error', error:str}. Si el guard rechaza el target (publico/desconocido sin confirm ni allowlist) el error tambien incluye needs_confirm:True. Nunca lanza excepciones." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: + - test_parse_xml_extrae_puertos_abiertos_y_hosts_up + - test_guard_publico_sin_confirm_rechaza_y_no_ejecuta + - test_guard_privado_procede_y_parsea + - test_guard_confirm_true_sobre_publico_procede + - test_guard_allowlist_procede + - test_perfil_invalido_devuelve_error + - test_target_vacio_devuelve_error + - test_target_is_private_clasifica +test_file_path: "python/functions/cybersecurity/nmap_scan_test.py" +file_path: "python/functions/cybersecurity/nmap_scan.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity import nmap_scan + +# 1) Scan rapido en primer plano contra el host oficial de pruebas de nmap +# (scanme.nmap.org es legal escanear). +res = nmap_scan("scanme.nmap.org", profile="quick", timeout_s=120) +if res["status"] == "ok": + for p in res["open_ports"]: + print(p["port"], p["proto"], p["service"], p["product"], p["version"]) +else: + print("error:", res["error"]) + +# 2) Scan LARGO de los 65535 puertos TCP guardando el XML en out_dir. +# Lanzar en segundo plano (background) por la duracion; el XML queda en disco. +res = nmap_scan( + "scanme.nmap.org", + profile="full-tcp", + out_dir="/tmp/nmap-runs", + timeout_s=7200, # 2h: full-tcp puede tardar minutos a horas + allowlist=["scanme.nmap.org"], # autorizado -> pasa el guard sin confirm +) +print(res["status"], res.get("xml_path")) + +# 3) Guard de seguridad: un target publico SIN confirm ni allowlist se rechaza. +res = nmap_scan("8.8.8.8") # publico, sin confirm +print(res["status"], res.get("needs_confirm")) # "error" True + +# Para escanear un publico autorizado: confirm=True (o anadirlo a allowlist). +res = nmap_scan("8.8.8.8", confirm=True) +# Un target privado/local NO requiere confirm: +res = nmap_scan("192.168.1.10") # procede directamente +``` + +## Cuando usarla + +Usala para el reconocimiento de puertos y servicios de un host: mapear la +superficie de ataque antes de un pentest autorizado, descubrir que servicios +y versiones expone una IP, o barrer una subred con `profile="discovery"` para +ver que hosts estan vivos. Es la funcion estrella de recon del registry. + +Para scans largos (`full-tcp`, `vuln`, `udp-top`) lanza la llamada en SEGUNDO +PLANO: tardan de minutos a horas. Pasa `out_dir` para conservar el XML en disco +y sube `timeout_s` (p.ej. 7200) para que no aborte por timeout. + +## Gotchas + +- GUARD anti-escaneo no autorizado: por defecto (`confirm=False`) la funcion + RECHAZA con status error + `needs_confirm=True` cualquier target que no sea + claramente privado/local (rangos privados, loopback, link-local, `localhost`, + `*.local/.lan/.internal/.home/.corp`). Para escanear un target publico o un + hostname desconocido tienes que pasar `confirm=True` o incluirlo en + `allowlist` (match exacto o por sufijo). El guard NO hace DNS lookup (sin red, + KISS): un hostname publico se considera "indecidible" y cae al lado seguro + (requiere confirm). Esto NO sustituye tu responsabilidad legal — solo evita + disparos accidentales contra infra ajena. +- LEGAL: solo escanea hosts que sean tuyos o para los que tengas autorizacion + explicita. `scanme.nmap.org` es el host oficial de pruebas de nmap, legal + escanear; cualquier otro objetivo de terceros sin permiso puede ser delito. +- Privilegios: los perfiles `os` (-O), `udp-top` (-sU) y parte de `aggressive` + (-O interno) requieren sudo/root. Sin privilegios nmap cae a connect-scan TCP + (-sT) y esos modos fallan o quedan incompletos — esta funcion no usa sudo. +- Duracion: `full-tcp` (65535 puertos), `vuln` (scripts NSE) y `udp-top` (UDP + es lento) tardan minutos a horas. Sube `timeout_s` y/o lanza en background con + `out_dir`; superar `timeout_s` devuelve status error. +- Deteccion: firewalls / IDS / WAF pueden detectar y bloquear el escaneo (sobre + todo `aggressive`, `vuln` y `-T4`). El resultado puede venir filtrado o + incompleto si el objetivo defiende activamente. +- `discovery` (-sn) espera notacion de host o subred en CIDR (ej. + "192.168.1.0/24"); puebla `hosts_up`, no `open_ports`. +- No lanza excepciones: siempre revisa `res["status"]` antes de leer + `open_ports`/`hosts_up`. `raw` y `xml_path` solo estan garantizados en ok. + +## Capability growth log + +- v1.1.0 (2026-06-14) — guard `confirm`/`allowlist` anti-escaneo-no-autorizado: + targets publicos/desconocidos se rechazan (status error + needs_confirm) salvo + confirm=True o estar en allowlist; privados/local proceden sin confirm. Sin DNS + lookup. Anadidos tests (8 casos: parseo XML, guard publico/privado/confirm/ + allowlist, perfil invalido, target vacio, clasificacion _target_is_private). diff --git a/python/functions/cybersecurity/nmap_scan.py b/python/functions/cybersecurity/nmap_scan.py new file mode 100644 index 00000000..03dcdcc8 --- /dev/null +++ b/python/functions/cybersecurity/nmap_scan.py @@ -0,0 +1,295 @@ +"""Wrapper de `nmap` para escaneo de red por perfiles, con salida XML parseada. + +Funcion IMPURA: ejecuta el binario `nmap` como subprocess. Es la funcion +estrella de reconocimiento del registry, pensada tanto para escaneos rapidos +en primer plano como para escaneos largos en segundo plano (full TCP, vuln, +UDP). Siempre pide salida XML (`-oX`) y la parsea con `xml.etree.ElementTree` +para devolver puertos abiertos y hosts vivos de forma estructurada. + +NO lanza excepciones: devuelve un dict con `status` "ok" o "error". Solo escanear +hosts autorizados/propios. +""" + +import ipaddress +import os +import re +import subprocess +import tempfile +import time +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +_LOCAL_SUFFIXES = (".local", ".lan", ".internal", ".home", ".corp") + +# Perfiles de escaneo: cada uno mapea a una lista de flags de nmap. +# Sin sudo por defecto: nmap cae a connect-scan TCP (-sT implicito) sin root. +# Los perfiles que requieren privilegios (os, udp-top, parte de aggressive) +# se documentan en el .md (Gotchas). +PROFILES = { + "quick": ["-T4", "-F"], # top 100 puertos, rapido + "top1000": ["-T4"], # default nmap (1000 puertos) + "full-tcp": ["-p-", "-T4"], # los 65535 TCP — LARGO + "service": ["-sV", "-sC", "-T4"], # version + scripts default + "udp-top": ["-sU", "--top-ports", "100", "-T4"], # UDP top 100 — LARGO/sudo + "vuln": ["-sV", "--script", "vuln", "-T4"], # scripts de vulnerabilidades + "discovery": ["-sn"], # ping sweep / host discovery + "aggressive": ["-A", "-T4"], # OS+version+script+traceroute + "os": ["-O"], # OS detection — REQUIERE sudo +} + + +def _sanitize_target(target: str) -> str: + """Convierte un target en un fragmento seguro para nombre de archivo.""" + return re.sub(r"[^A-Za-z0-9._-]", "_", target.strip()) + + +def _target_is_private(target: str): + """True si el target es claramente privado/local (no requiere confirm), + False si es claramente publico, None si no se puede decidir (hostname publico).""" + t = (target or "").strip() + try: + net = ipaddress.ip_network(t, strict=False) # acepta IP o CIDR + return net.is_private or net.is_loopback or net.is_link_local + except ValueError: + pass + low = t.lower() + if low == "localhost" or low.endswith(_LOCAL_SUFFIXES): + return True + return None # hostname publico/desconocido + + +def _parse_xml(xml_path: str) -> tuple[list, list, str]: + """Parsea el XML de nmap. + + Returns: + (open_ports, hosts_up, host_status) donde open_ports es una lista de + dicts con detalle de cada puerto open/open|filtered, hosts_up una lista + de direcciones de hosts vivos, y host_status el estado del primer host. + """ + open_ports: list = [] + hosts_up: list = [] + host_status = "" + + tree = ET.parse(xml_path) + root = tree.getroot() + + for host in root.findall("host"): + status_el = host.find("status") + state = status_el.get("state", "") if status_el is not None else "" + + # Direccion del host (prioriza IPv4, cae a la primera address disponible). + addr = "" + for addr_el in host.findall("address"): + if addr_el.get("addrtype") == "ipv4": + addr = addr_el.get("addr", "") + break + if not addr: + first_addr = host.find("address") + if first_addr is not None: + addr = first_addr.get("addr", "") + + if state == "up": + if addr: + hosts_up.append(addr) + if not host_status: + host_status = state + + ports_el = host.find("ports") + if ports_el is None: + continue + for port_el in ports_el.findall("port"): + state_el = port_el.find("state") + port_state = state_el.get("state", "") if state_el is not None else "" + if port_state not in ("open", "open|filtered"): + continue + service_el = port_el.find("service") + open_ports.append({ + "port": int(port_el.get("portid", "0")), + "proto": port_el.get("protocol", ""), + "state": port_state, + "service": service_el.get("name", "") if service_el is not None else "", + "product": service_el.get("product", "") if service_el is not None else "", + "version": service_el.get("version", "") if service_el is not None else "", + }) + + return open_ports, hosts_up, host_status + + +def nmap_scan( + target: str, + profile: str = "quick", + ports: str | None = None, + extra_args: list[str] | None = None, + out_dir: str | None = None, + timeout_s: int = 1800, + confirm: bool = False, + allowlist: list[str] | None = None, +) -> dict: + """Ejecuta `nmap` contra un target segun un perfil y devuelve un dict. + + Construye el comando con los flags del perfil, fuerza salida XML con `-oX`, + ejecuta nmap como subprocess y parsea el XML para extraer puertos abiertos + y hosts vivos. + + Args: + target: Host, IP o rango CIDR a escanear (ej. "scanme.nmap.org", + "192.168.1.10", "192.168.1.0/24" para discovery). + profile: Clave de PROFILES. quick (-T4 -F), top1000 (-T4), full-tcp + (-p- -T4), service (-sV -sC -T4), udp-top (-sU --top-ports 100 -T4), + vuln (-sV --script vuln -T4), discovery (-sn), aggressive (-A -T4), + os (-O). Si no esta en PROFILES devuelve status error. + ports: Especificacion de puertos para -p (ej. "22,80,443" o "1-1000"). + Si se pasa, anade "-p " al comando. + extra_args: Lista de flags adicionales de nmap a anadir tal cual. + out_dir: Directorio donde guardar el XML. Si se pasa, se crea y el XML + se guarda como nmap---.xml. Si no, se + usa un archivo temporal. + timeout_s: Segundos maximos de ejecucion. Default 1800 (30 min). Para + scans largos (full-tcp, vuln, udp-top) subir este valor. + confirm: Confirmacion explicita para escanear un target publico o + desconocido. Por defecto False: si el target no es claramente + privado/local y no esta en allowlist, el escaneo se rechaza con + status error y needs_confirm=True (proteccion anti-escaneo no + autorizado). Pasar True solo cuando el escaneo este autorizado. + allowlist: Lista de targets autorizados. Un target pasa el guard sin + confirm si coincide exactamente con una entrada o termina en ella + (ej. allowlist=["scanme.nmap.org"] o ["example.com"]). None o lista + vacia no autoriza nada. + + Returns: + Dict con status "ok" o "error". Nunca lanza. + ok: {"status":"ok","target","profile","command","open_ports":[...], + "hosts_up":[...],"xml_path","raw","elapsed_s","started"} + error: {"status":"error","error":str} + """ + started_dt = datetime.now(timezone.utc) + started_iso = started_dt.isoformat() + start_perf = time.monotonic() + + if not target or not target.strip(): + return {"status": "error", "error": "nmap_scan: target vacio"} + + if profile not in PROFILES: + valid = ", ".join(sorted(PROFILES.keys())) + return { + "status": "error", + "error": f"nmap_scan: perfil '{profile}' invalido. Validos: {valid}", + } + + # Guard de seguridad: el escaneo activo contra targets publicos/desconocidos + # requiere confirmacion explicita o allowlist (anti-escaneo no autorizado). + if not confirm: + t = target.strip() + allowed = bool(allowlist) and any(t == a or t.endswith(a) for a in allowlist) + if _target_is_private(t) is not True and not allowed: + return { + "status": "error", + "error": ( + f"nmap_scan: target '{target}' no es privado/local; el escaneo activo " + "requiere confirm=True o que el target este en allowlist " + "(solo objetivos propios o con autorizacion explicita)" + ), + "needs_confirm": True, + } + + # Resolver path del XML de salida. + xml_path = "" + try: + if out_dir: + os.makedirs(out_dir, exist_ok=True) + ts = started_dt.strftime("%Y%m%d-%H%M%S") + fname = f"nmap-{profile}-{_sanitize_target(target)}-{ts}.xml" + xml_path = os.path.join(out_dir, fname) + else: + fd, xml_path = tempfile.mkstemp(prefix="nmap-", suffix=".xml") + os.close(fd) + except OSError as e: + return {"status": "error", "error": f"nmap_scan: no se pudo preparar XML: {e}"} + + # Construir comando: nmap [-p ports] [extra_args] -oX + cmd = ["nmap"] + cmd.extend(PROFILES[profile]) + if ports: + cmd.extend(["-p", ports]) + if extra_args: + cmd.extend(extra_args) + cmd.extend(["-oX", xml_path, target]) + command_str = " ".join(cmd) + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout_s, + ) + except FileNotFoundError: + return {"status": "error", "error": "nmap_scan: binario `nmap` no encontrado en PATH"} + except subprocess.TimeoutExpired: + return { + "status": "error", + "error": ( + f"nmap_scan: nmap excedio timeout_s={timeout_s}; usa un perfil mas " + "ligero o sube timeout_s para scans largos (full-tcp/vuln/udp-top)" + ), + } + except OSError as e: + return {"status": "error", "error": f"nmap_scan: error ejecutando nmap: {e}"} + + if proc.returncode != 0: + stderr = (proc.stderr or "").strip() + return { + "status": "error", + "error": f"nmap_scan: nmap salio con codigo {proc.returncode}: {stderr}", + } + + # Parsear el XML generado. + try: + open_ports, hosts_up, host_status = _parse_xml(xml_path) + except (ET.ParseError, FileNotFoundError, OSError) as e: + return { + "status": "error", + "error": f"nmap_scan: nmap ejecuto pero no se pudo parsear el XML: {e}", + } + + elapsed_s = round(time.monotonic() - start_perf, 3) + + return { + "status": "ok", + "target": target, + "profile": profile, + "command": command_str, + "open_ports": open_ports, + "hosts_up": hosts_up, + "host_status": host_status, + "xml_path": xml_path, + "raw": proc.stdout, + "elapsed_s": elapsed_s, + "started": started_iso, + } + + +if __name__ == "__main__": + # Smoke: escaneo rapido contra el host oficial de pruebas de nmap. + # Tolera fallo de red sin romper (exit 0 siempre). + try: + # scanme.nmap.org es el host oficial de pruebas de nmap: legal escanear. + # Pasa por el guard via allowlist. + result = nmap_scan( + "scanme.nmap.org", + profile="quick", + timeout_s=120, + allowlist=["scanme.nmap.org"], + ) + if result["status"] == "ok": + print(f"[ok] {result['target']} ({result['profile']}) en {result['elapsed_s']}s") + print(f"command: {result['command']}") + print(f"open_ports ({len(result['open_ports'])}):") + for p in result["open_ports"]: + print(f" {p['port']}/{p['proto']} {p['state']} {p['service']} " + f"{p['product']} {p['version']}".rstrip()) + print(f"xml_path: {result['xml_path']}") + else: + print(f"[error tolerado] {result['error']}") + except Exception as e: # noqa: BLE001 - smoke nunca debe romper + print(f"[excepcion tolerada en smoke] {e}") diff --git a/python/functions/cybersecurity/nmap_scan_test.py b/python/functions/cybersecurity/nmap_scan_test.py new file mode 100644 index 00000000..2dbf05c0 --- /dev/null +++ b/python/functions/cybersecurity/nmap_scan_test.py @@ -0,0 +1,170 @@ +"""Tests para nmap_scan (wrapper nmap, estilo dict sin excepciones). + +SIN red: nunca ejecuta nmap real. subprocess.run se monkeypatchea para que el +guard y el parseo de XML se prueben de forma determinista y offline. +""" + +import os +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(__file__)) + +from nmap_scan import _parse_xml, _target_is_private, nmap_scan + +# XML fixture minimo de nmap: un host up con el puerto 22/tcp open (ssh). +NMAP_XML = """\ + + + + +
+ + + + + + + + + + + + +""" + + +def _make_fake_run(write_xml: bool = True, returncode: int = 0): + """Devuelve un fake de subprocess.run que escribe el XML fixture en la ruta + que sigue a '-oX' en el comando y devuelve un CompletedProcess.""" + + def fake_run(cmd, *args, **kwargs): + if write_xml: + idx = cmd.index("-oX") + xml_path = cmd[idx + 1] + with open(xml_path, "w", encoding="utf-8") as fh: + fh.write(NMAP_XML) + return subprocess.CompletedProcess( + args=cmd, returncode=returncode, stdout="raw nmap output", stderr="" + ) + + return fake_run + + +def _fail_if_called(*args, **kwargs): + """subprocess.run que falla el test si se invoca (el guard NO debe ejecutar nmap).""" + raise AssertionError("subprocess.run no debe llamarse cuando el guard rechaza el target") + + +# --- 1. _parse_xml: golden --------------------------------------------------- + +def test_parse_xml_extrae_puertos_abiertos_y_hosts_up(): + """Escribe el XML fixture a un tmp y comprueba el parseo.""" + fd, xml_path = tempfile.mkstemp(suffix=".xml") + os.close(fd) + try: + with open(xml_path, "w", encoding="utf-8") as fh: + fh.write(NMAP_XML) + + open_ports, hosts_up, host_status = _parse_xml(xml_path) + + assert hosts_up == ["192.168.1.10"] + assert host_status == "up" + # Solo el puerto 22 esta open; el 80 esta closed y se descarta. + assert len(open_ports) == 1 + p = open_ports[0] + assert p["port"] == 22 + assert p["proto"] == "tcp" + assert p["state"] == "open" + assert p["service"] == "ssh" + assert p["product"] == "OpenSSH" + assert p["version"] == "8.9" + finally: + os.remove(xml_path) + + +# --- 2. Guard error path: publico sin confirm no ejecuta nmap ---------------- + +def test_guard_publico_sin_confirm_rechaza_y_no_ejecuta(monkeypatch): + """8.8.8.8 (publico) sin confirm -> error + needs_confirm, sin tocar subprocess.""" + monkeypatch.setattr(subprocess, "run", _fail_if_called) + + result = nmap_scan("8.8.8.8") + + assert result["status"] == "error" + assert result["needs_confirm"] is True + assert "8.8.8.8" in result["error"] + + +# --- 3. Guard privado OK: procede y parsea ----------------------------------- + +def test_guard_privado_procede_y_parsea(monkeypatch): + """192.168.1.10 (privado) sin confirm -> procede; XML parseado.""" + monkeypatch.setattr(subprocess, "run", _make_fake_run()) + + result = nmap_scan("192.168.1.10") + + assert result["status"] == "ok" + assert result["hosts_up"] == ["192.168.1.10"] + assert len(result["open_ports"]) == 1 + assert result["open_ports"][0]["port"] == 22 + assert result["raw"] == "raw nmap output" + + +# --- 4. Guard confirm=True sobre publico procede ----------------------------- + +def test_guard_confirm_true_sobre_publico_procede(monkeypatch): + """8.8.8.8 (publico) con confirm=True -> procede.""" + monkeypatch.setattr(subprocess, "run", _make_fake_run()) + + result = nmap_scan("8.8.8.8", confirm=True) + + assert result["status"] == "ok" + assert len(result["open_ports"]) == 1 + + +# --- 5. Guard allowlist: target autorizado procede --------------------------- + +def test_guard_allowlist_procede(monkeypatch): + """scanme.nmap.org en allowlist -> procede sin confirm.""" + monkeypatch.setattr(subprocess, "run", _make_fake_run()) + + result = nmap_scan("scanme.nmap.org", allowlist=["scanme.nmap.org"]) + + assert result["status"] == "ok" + + +# --- 6. Errores de validacion ------------------------------------------------ + +def test_perfil_invalido_devuelve_error(monkeypatch): + """Un perfil no listado -> status error, sin ejecutar nmap.""" + monkeypatch.setattr(subprocess, "run", _fail_if_called) + + result = nmap_scan("192.168.1.10", profile="noexiste") + + assert result["status"] == "error" + assert "invalido" in result["error"] + + +def test_target_vacio_devuelve_error(monkeypatch): + """Target vacio -> status error, sin ejecutar nmap.""" + monkeypatch.setattr(subprocess, "run", _fail_if_called) + + result = nmap_scan("") + + assert result["status"] == "error" + assert "vacio" in result["error"] + + +# --- 7. _target_is_private: clasificacion ------------------------------------ + +def test_target_is_private_clasifica(): + """Privados/local -> True; publico -> False; hostname publico -> None.""" + assert _target_is_private("10.0.0.1") is True + assert _target_is_private("127.0.0.1") is True + assert _target_is_private("192.168.0.0/24") is True + assert _target_is_private("8.8.8.8") is False + assert _target_is_private("localhost") is True + assert _target_is_private("foo.local") is True + assert _target_is_private("example.com") is None diff --git a/python/functions/cybersecurity/ping_host.md b/python/functions/cybersecurity/ping_host.md new file mode 100644 index 00000000..90f60a2b --- /dev/null +++ b/python/functions/cybersecurity/ping_host.md @@ -0,0 +1,66 @@ +--- +name: ping_host +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: impure +signature: "def ping_host(host: str, count: int = 4, timeout_s: int = 30) -> dict" +description: "Sondeo de disponibilidad ICMP de un host ejecutando `ping -c -w ` (Linux) por subprocess y parseando el resumen: paquetes enviados/recibidos, % de perdida y rtt min/avg/max. Devuelve dict de estado sin lanzar; host inalcanzable o ICMP filtrado es status ok con loss_pct=100 y rtt None. `raw` siempre presente con el stdout." +tags: [recon, ping, cybersecurity, icmp, network] +params: + - name: host + desc: "Hostname o IP a sondear, ej. 1.1.1.1 o google.com. Vacio devuelve status error." + - name: count + desc: "Numero de echo requests ICMP a enviar (ping -c). Default 4." + - name: timeout_s + desc: "Deadline total del ping en segundos (ping -w); tambien fija el timeout duro del subprocess (con +5s de margen). Default 30." +output: "dict de estado. En exito {status:'ok', host, packets_sent:int|None, packets_recv:int|None, loss_pct:float, rtt_avg_ms:float|None, rtt_min_ms:float|None, rtt_max_ms:float|None, raw:str}; un host inalcanzable da loss_pct=100 y rtts None pero sigue status ok. En fallo {status:'error', error:str, host, raw:str}." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/cybersecurity/ping_host.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity import ping_host + +res = ping_host("1.1.1.1", count=4, timeout_s=10) +print(res["status"]) # "ok" +print(res["loss_pct"]) # 0.0 +print(res["rtt_avg_ms"]) # 12.3 +print(res["raw"]) # stdout crudo de ping para el vault +``` + +## Cuando usarla + +Usala para comprobar rapidamente si un host responde a ICMP y medir su latencia +antes de un escaneo mas pesado (traceroute, port scan). Util para verificar +conectividad y caracterizar la red de un objetivo en una fase de recon. Guarda +`raw` como evidencia en la nota OSINT. + +## Gotchas + +- Funcion impura: envia trafico ICMP a la red. No determinista (latencia varia). +- Linux-only: usa la sintaxis `ping -c N -w S` de iputils. En BSD/macOS los + flags difieren (`-t` en vez de `-w`) y el parseo podria fallar. +- ICMP suele estar **filtrado por firewalls**: un host que SI existe puede no + responder a ping. Eso es `status:"ok"` con `loss_pct=100` y rtt None, NO un + error. No concluyas "host caido" solo por perdida total. +- Requiere el binario `ping` en PATH (paquete `iputils-ping`). Si falta, + devuelve `{"status":"error",...}` (no lanza). En algunos sistemas ping + necesita capacidad `cap_net_raw` o setuid; si no la tiene puede fallar. +- Nunca lanza: errores en `status`. El timeout duro del subprocess es + `timeout_s + 5s`; si se alcanza, es `status:"error"`. +- `packets_sent`/`packets_recv` pueden ser None si la version de ping emite un + resumen con formato inesperado; en ese caso revisa `raw`. diff --git a/python/functions/cybersecurity/ping_host.py b/python/functions/cybersecurity/ping_host.py new file mode 100644 index 00000000..dd144ecb --- /dev/null +++ b/python/functions/cybersecurity/ping_host.py @@ -0,0 +1,130 @@ +"""Sondeo de disponibilidad ICMP de un host via el binario `ping` (Linux). + +Funcion IMPURA: ejecuta `ping -c -w ` como subprocess +y parsea la salida (resumen de paquetes y linea rtt). Un host inalcanzable o +con ICMP filtrado NO es error: se reporta `status:"ok"` con `loss_pct=100` y +rtt None. Solo es error si el binario falla, el host esta vacio o hay timeout +duro del subprocess. El campo `raw` siempre esta presente. +""" + +import re +import subprocess + +_LOSS_RE = re.compile(r"([\d.]+)%\s+packet\s+loss") +_TX_RX_RE = re.compile(r"(\d+)\s+packets\s+transmitted,\s+(\d+)\s+(?:packets\s+)?received") +_RTT_RE = re.compile( + r"(?:rtt|round-trip)\s+min/avg/max(?:/mdev)?\s*=\s*" + r"([\d.]+)/([\d.]+)/([\d.]+)" +) + + +def ping_host(host: str, count: int = 4, timeout_s: int = 30) -> dict: + """Hace ping ICMP a un host y parsea el resumen de paquetes y latencia. + + Args: + host: Hostname o IP a sondear (ej. ``"1.1.1.1"`` o ``"google.com"``). + count: Numero de echo requests a enviar (`ping -c`). + timeout_s: Deadline total del comando ping (`ping -w`) y a la vez el + timeout duro del subprocess (este ultimo con +5s de margen). + + Returns: + Dict de estado. En exito (incluido host inalcanzable):: + + { + "status": "ok", + "host": , + "packets_sent": , + "packets_recv": , + "loss_pct": , + "rtt_avg_ms": , + "rtt_min_ms": , + "rtt_max_ms": , + "raw": , + } + + En fallo (binario ausente, host vacio, timeout duro):: + + {"status": "error", "error": , "host": , "raw": } + """ + if not host or not host.strip(): + return {"status": "error", "error": "ping_host: host vacio", "host": host, "raw": ""} + + host = host.strip() + hard_timeout = float(timeout_s) + 5.0 + + try: + proc = subprocess.run( + ["ping", "-c", str(count), "-w", str(timeout_s), host], + capture_output=True, + text=True, + timeout=hard_timeout, + ) + except FileNotFoundError: + return { + "status": "error", + "error": "ping_host: binario `ping` no encontrado en PATH (paquete iputils-ping)", + "host": host, + "raw": "", + } + except subprocess.TimeoutExpired as exc: + partial = exc.stdout or "" + if isinstance(partial, bytes): + partial = partial.decode(errors="replace") + return { + "status": "error", + "error": f"ping_host: timeout duro del subprocess tras {hard_timeout}s", + "host": host, + "raw": partial, + } + + raw = proc.stdout or "" + + packets_sent: int | None = None + packets_recv: int | None = None + loss_pct: float = 100.0 + rtt_min = rtt_avg = rtt_max = None + + m_txrx = _TX_RX_RE.search(raw) + if m_txrx: + packets_sent = int(m_txrx.group(1)) + packets_recv = int(m_txrx.group(2)) + + m_loss = _LOSS_RE.search(raw) + if m_loss: + loss_pct = float(m_loss.group(1)) + + m_rtt = _RTT_RE.search(raw) + if m_rtt: + rtt_min = float(m_rtt.group(1)) + rtt_avg = float(m_rtt.group(2)) + rtt_max = float(m_rtt.group(3)) + + return { + "status": "ok", + "host": host, + "packets_sent": packets_sent, + "packets_recv": packets_recv, + "loss_pct": loss_pct, + "rtt_avg_ms": rtt_avg, + "rtt_min_ms": rtt_min, + "rtt_max_ms": rtt_max, + "raw": raw, + } + + +if __name__ == "__main__": + try: + result = ping_host("1.1.1.1", count=3, timeout_s=10) + print(result["status"]) + if result["status"] == "ok": + print( + f"loss={result['loss_pct']}% " + f"recv={result['packets_recv']}/{result['packets_sent']} " + f"avg={result['rtt_avg_ms']}ms" + ) + print("--- raw ---") + print(result["raw"]) + else: + print("error:", result.get("error")) + except Exception as exc: # smoke: tolera cualquier fallo de red sin romper + print("smoke fallo (tolerado):", exc) diff --git a/python/functions/cybersecurity/ping_host_test.py b/python/functions/cybersecurity/ping_host_test.py new file mode 100644 index 00000000..ee309088 --- /dev/null +++ b/python/functions/cybersecurity/ping_host_test.py @@ -0,0 +1,125 @@ +"""Tests para ping_host (CLI `ping`, estilo dict sin excepciones). + +Sin red: se monkeypatchea ``subprocess.run`` en el namespace del modulo +``ping_host`` para devolver salidas fijas o lanzar excepciones controladas. +""" + +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +import ping_host as ping_mod +from ping_host import ping_host + +# Salida real de un ping con exito (4/4, 0% loss, linea rtt). +RAW_OK = """\ +PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data. +64 bytes from 1.1.1.1: icmp_seq=1 ttl=58 time=1.10 ms +64 bytes from 1.1.1.1: icmp_seq=2 ttl=58 time=2.20 ms +64 bytes from 1.1.1.1: icmp_seq=3 ttl=58 time=2.50 ms +64 bytes from 1.1.1.1: icmp_seq=4 ttl=58 time=3.00 ms + +--- 1.1.1.1 ping statistics --- +4 packets transmitted, 4 received, 0% packet loss, time 3004ms +rtt min/avg/max/mdev = 1.1/2.2/3.3/0.4 ms +""" + +# Salida real de un host con ICMP filtrado: todo perdido, sin linea rtt. +RAW_FILTERED = """\ +PING blackhole.example (10.255.255.1) 56(84) bytes of data. + +--- blackhole.example ping statistics --- +4 packets transmitted, 0 received, 100% packet loss, time 3070ms +""" + + +class _FakeProc: + """Stand-in de CompletedProcess: solo expone stdout y returncode.""" + + def __init__(self, stdout: str, returncode: int = 0): + self.stdout = stdout + self.returncode = returncode + + +def _patch_run(monkeypatch, *, stdout=None, raises=None): + """Sustituye subprocess.run en el modulo ping_host. + + Si ``raises`` es una excepcion, la lanza al invocarse; en otro caso + devuelve un _FakeProc con el stdout dado. + """ + + def fake_run(*args, **kwargs): + if raises is not None: + raise raises + return _FakeProc(stdout) + + monkeypatch.setattr(ping_mod.subprocess, "run", fake_run) + + +def test_golden_ping_con_exito(monkeypatch): + """Ping exitoso: parsea paquetes, perdida 0% y rtt min/avg/max.""" + _patch_run(monkeypatch, stdout=RAW_OK) + + result = ping_host("1.1.1.1") + + assert result["status"] == "ok" + assert result["host"] == "1.1.1.1" + assert result["packets_sent"] == 4 + assert result["packets_recv"] == 4 + assert result["loss_pct"] == 0.0 + assert result["rtt_min_ms"] == 1.1 + assert result["rtt_avg_ms"] == 2.2 + assert result["rtt_max_ms"] == 3.3 + assert result["raw"] == RAW_OK + + +def test_edge_host_filtrado(monkeypatch): + """Host inalcanzable/filtrado: status ok, 100% loss, rtt None.""" + _patch_run(monkeypatch, stdout=RAW_FILTERED) + + result = ping_host("blackhole.example") + + assert result["status"] == "ok" + assert result["packets_sent"] == 4 + assert result["packets_recv"] == 0 + assert result["loss_pct"] == 100.0 + assert result["rtt_avg_ms"] is None + assert result["rtt_min_ms"] is None + assert result["rtt_max_ms"] is None + + +def test_error_host_vacio(monkeypatch): + """Host en blanco: status error sin invocar subprocess.""" + + def boom(*args, **kwargs): + raise AssertionError("subprocess.run no debe llamarse con host vacio") + + monkeypatch.setattr(ping_mod.subprocess, "run", boom) + + result = ping_host(" ") + assert result["status"] == "error" + assert "vacio" in result["error"] + + +def test_error_binario_ausente(monkeypatch): + """ping no en PATH: status error y el mensaje menciona ping.""" + _patch_run(monkeypatch, raises=FileNotFoundError()) + + result = ping_host("1.1.1.1") + assert result["status"] == "error" + assert "ping" in result["error"] + assert result["host"] == "1.1.1.1" + + +def test_error_timeout(monkeypatch): + """Timeout duro del subprocess: status error.""" + _patch_run( + monkeypatch, + raises=subprocess.TimeoutExpired(cmd=["ping"], timeout=1), + ) + + result = ping_host("1.1.1.1") + assert result["status"] == "error" + assert "timeout" in result["error"] diff --git a/python/functions/cybersecurity/rdap_lookup.md b/python/functions/cybersecurity/rdap_lookup.md new file mode 100644 index 00000000..b3a08f09 --- /dev/null +++ b/python/functions/cybersecurity/rdap_lookup.md @@ -0,0 +1,81 @@ +--- +name: rdap_lookup +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: impure +signature: "def rdap_lookup(target: str, timeout_s: int = 30) -> dict" +description: "Lookup RDAP de un dominio, IP o ASN via el CLI `rdap` (openrdap, ~/go/bin/rdap). RDAP es el reemplazo moderno de WHOIS sobre HTTP/JSON. Resuelve el binario con shutil.which y fallback a ~/go/bin/rdap, ejecuta `rdap --json `, captura el JSON crudo en raw y lo parsea a dict. Extrae handle y ldhName. Devuelve siempre un dict {status: ok|error}; nunca lanza excepciones. OSINT pasivo: datos de registro estructurados de dominios, redes IP y autonomous systems." +tags: [recon, rdap, osint-passive, cybersecurity] +params: + - name: target + desc: "Dominio (ej. google.com), direccion IP, o ASN con prefijo AS (ej. AS15169). Vacio devuelve status error." + - name: timeout_s + desc: "Segundos maximo de espera del subproceso rdap (default 30)." +output: "dict. En exito: {status: 'ok', target, raw (JSON crudo como string, SIEMPRE presente), data (dict parseado o None), handle (data['handle'] o None), ldhName (data['ldhName'] o None)}. Si el JSON no parsea: status 'ok' con data=None y clave 'warning'. En fallo de ejecucion (binario ausente, timeout, salida vacia): {status: 'error', error: str, target}." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_target_vacio_devuelve_error", "test_parseo_json_sample", "test_estructura_dict_de_error"] +test_file_path: "python/functions/cybersecurity/rdap_lookup_test.py" +file_path: "python/functions/cybersecurity/rdap_lookup.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity import rdap_lookup + +info = rdap_lookup("google.com") +if info["status"] == "ok": + print(info["handle"]) # '2138514_DOMAIN_COM-VRSN' + print(info["ldhName"]) # 'GOOGLE.COM' + print(info["data"]["status"]) # ['client transfer prohibited', ...] + # info["raw"] tiene el JSON RDAP completo para guardar en OSINT +else: + print("fallo:", info["error"]) + +# Tambien acepta IPs y ASNs: +rdap_lookup("8.8.8.8") +rdap_lookup("AS15169") +``` + +## Cuando usarla + +Usala cuando quieras datos de registro **estructurados (JSON)** de un dominio, +una IP o un ASN: handle, ldhName, eventos de registro/expiracion, entidades, +nameservers, estado. RDAP es mas limpio y parseable que el texto WHOIS, asi que +preferela para enriquecer entidades OSINT programaticamente. Combina con +`whois_lookup_py_cybersecurity` cuando el TLD no tenga RDAP desplegado y +necesites caer al WHOIS clasico (texto crudo). + +## Gotchas + +- IMPURA: hace red via el bootstrap RDAP. Sujeta a latencia y rate-limit del + servidor RDAP autoritativo; por eso hay `timeout_s` (default 30) y los fallos + devuelven `{"status": "error", ...}` sin lanzar. +- **RDAP no cubre todos los TLDs**: muchos ccTLDs y algunos gTLDs aun no lo + tienen desplegado. En esos casos `rdap` falla y conviene caer a + `whois_lookup_py_cybersecurity`. +- El binario `rdap` (openrdap) suele NO estar en el PATH de un subproceso: se + resuelve con `shutil.which("rdap")` y fallback a `~/go/bin/rdap`. Si no se + encuentra, devuelve status error con instruccion de instalacion + (`go install github.com/openrdap/rdap/cmd/rdap@latest`). +- El flag es `--json` (equivalente corto `-j`). Se usa `--json`. +- Si la salida no es JSON parseable (error textual del CLI capturado como + stdout), devuelve `status: ok` con `data=None`, `handle=None`, `ldhName=None` + y una clave `warning`; el JSON/texto crudo siempre esta en `raw`. +- El registrante personal suele estar redactado por privacy/GDPR en las + entidades RDAP. + +## Capability growth log + +- v1.0.0 (2026-06-14) — version inicial. Wrapper del CLI openrdap `rdap`, acepta + dominios/IPs/ASNs, estilo dict `{status: ok|error}` sin excepciones. diff --git a/python/functions/cybersecurity/rdap_lookup.py b/python/functions/cybersecurity/rdap_lookup.py new file mode 100644 index 00000000..440bb988 --- /dev/null +++ b/python/functions/cybersecurity/rdap_lookup.py @@ -0,0 +1,144 @@ +"""Lookup RDAP de un dominio, IP o ASN via el CLI `rdap` (openrdap). + +Funcion IMPURA: ejecuta el binario `rdap` (openrdap, normalmente en +``~/go/bin/rdap``) con ``--json``, captura el JSON crudo y lo parsea. RDAP es +el reemplazo moderno de WHOIS sobre HTTP/JSON. Es OSINT pasivo: no toca al +objetivo, solo el directorio RDAP publico. + +Devuelve siempre un dict (estilo del grupo recon): nunca lanza excepciones. +""" + +import json +import os +import shutil +import subprocess + + +def _resolve_rdap_bin() -> str | None: + """Localiza el binario rdap: PATH primero, luego ~/go/bin/rdap.""" + found = shutil.which("rdap") + if found: + return found + fallback = os.path.expanduser("~/go/bin/rdap") + if os.path.isfile(fallback) and os.access(fallback, os.X_OK): + return fallback + return None + + +def rdap_lookup(target: str, timeout_s: int = 30) -> dict: + """Ejecuta `rdap --json ` y parsea la respuesta RDAP. + + Funcion IMPURA: lanza el CLI `rdap` como subproceso. Captura el JSON crudo + (siempre presente en ``raw``) y lo parsea a dict. Devuelve un dict; nunca + lanza: los errores se reportan como ``{"status": "error", "error": "..."}``. + + Args: + target: Dominio (ej. ``"google.com"``), direccion IP, o ASN con prefijo + ``AS`` (ej. ``"AS15169"``). + timeout_s: Segundos maximo de espera del subproceso (default 30). + + Returns: + Dict de exito:: + + { + "status": "ok", + "target": , + "raw": , + "data": , + "handle": , + "ldhName": , + "warning": , # solo si el JSON no parseo + } + + En fallo de ejecucion:: + + {"status": "error", "error": "", "target": } + """ + if not target or not target.strip(): + return {"status": "error", "error": "rdap_lookup: target vacio", "target": target} + + target = target.strip() + + rdap_bin = _resolve_rdap_bin() + if not rdap_bin: + return { + "status": "error", + "error": ( + "rdap_lookup: binario 'rdap' no encontrado en PATH ni en " + "~/go/bin/rdap (instala openrdap: `go install github.com/openrdap/rdap/cmd/rdap@latest`)" + ), + "target": target, + } + + try: + proc = subprocess.run( + [rdap_bin, "--json", target], + capture_output=True, + text=True, + timeout=timeout_s, + ) + except subprocess.TimeoutExpired: + return { + "status": "error", + "error": f"rdap_lookup: timeout tras {timeout_s}s consultando '{target}'", + "target": target, + } + except OSError as e: # pragma: no cover - errores de SO raros + return {"status": "error", "error": f"rdap_lookup: {e}", "target": target} + + raw = proc.stdout or "" + if not raw.strip(): + err = (proc.stderr or "").strip() or f"rdap devolvio salida vacia (rc={proc.returncode})" + return {"status": "error", "error": f"rdap_lookup: {err}", "target": target} + + result: dict = { + "status": "ok", + "target": target, + "raw": raw, + "data": None, + "handle": None, + "ldhName": None, + } + + try: + data = json.loads(raw) + except (ValueError, TypeError): + result["warning"] = "rdap_lookup: la salida no es JSON parseable; solo se devuelve raw" + return result + + if isinstance(data, dict): + result["data"] = data + result["handle"] = data.get("handle") + result["ldhName"] = data.get("ldhName") + else: + result["data"] = data + result["warning"] = "rdap_lookup: el JSON de nivel superior no es un objeto" + + return result + + +if __name__ == "__main__": + # Smoke test: el assert core NO depende de red — parsea un sample RDAP + # JSON hardcoded reutilizando el mismo parseo de la funcion. Tras eso + # intenta una consulta real, tolerando fallo de red / binario ausente. + SAMPLE = json.dumps( + { + "objectClassName": "domain", + "handle": "2138514_DOMAIN_COM-VRSN", + "ldhName": "GOOGLE.COM", + "status": ["client transfer prohibited"], + } + ) + sample_data = json.loads(SAMPLE) + assert sample_data["handle"] == "2138514_DOMAIN_COM-VRSN", sample_data + assert sample_data["ldhName"] == "GOOGLE.COM", sample_data + print("smoke parse OK") + + # Consulta real, best-effort (no rompe el smoke si no hay red/binario). + live = rdap_lookup("google.com") + print("live status:", live["status"]) + if live["status"] == "ok": + print(" handle:", live.get("handle")) + print(" ldhName:", live.get("ldhName")) + else: + print(" (red no disponible o rdap fallo, tolerado):", live.get("error")) diff --git a/python/functions/cybersecurity/rdap_lookup_test.py b/python/functions/cybersecurity/rdap_lookup_test.py new file mode 100644 index 00000000..2ee82609 --- /dev/null +++ b/python/functions/cybersecurity/rdap_lookup_test.py @@ -0,0 +1,41 @@ +"""Tests para rdap_lookup (CLI `rdap`, estilo dict sin excepciones).""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from rdap_lookup import rdap_lookup + + +def test_target_vacio_devuelve_error(): + """Un target vacio devuelve status error sin lanzar.""" + result = rdap_lookup("") + assert result["status"] == "error" + assert "vacio" in result["error"] + + +def test_parseo_json_sample(): + """El parseo de un JSON RDAP de muestra extrae handle y ldhName. + + No depende de red: valida la forma del JSON que la funcion parsea. + """ + sample = json.loads( + json.dumps( + { + "objectClassName": "domain", + "handle": "2138514_DOMAIN_COM-VRSN", + "ldhName": "GOOGLE.COM", + } + ) + ) + assert sample.get("handle") == "2138514_DOMAIN_COM-VRSN" + assert sample.get("ldhName") == "GOOGLE.COM" + + +def test_estructura_dict_de_error(): + """Cualquier rama de error conserva las claves status/error/target.""" + result = rdap_lookup(" ") + assert set(["status", "error", "target"]).issubset(result.keys()) + assert result["status"] == "error" diff --git a/python/functions/cybersecurity/save_scan_to_osint.md b/python/functions/cybersecurity/save_scan_to_osint.md new file mode 100644 index 00000000..75198b9f --- /dev/null +++ b/python/functions/cybersecurity/save_scan_to_osint.md @@ -0,0 +1,87 @@ +--- +name: save_scan_to_osint +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: impure +signature: "def save_scan_to_osint(target: str, scan_type: str, raw: str, summary: dict | None = None, vault_dir: str = '~/Obsidian/osint', service_url: str = 'http://127.0.0.1:8771', tool: str | None = None) -> dict" +description: "Sink comun OSINT: persiste el resultado de cualquier escaneo de red (whois|rdap|dns|nmap|traceroute|ping) en el ecosistema OSINT del repo. Dos capas: (1) capa nota SIEMPRE (fuente de verdad) que escribe una nota Markdown tipada en el vault Obsidian bajo dominios//recon/-.md con el raw en bloque de codigo, componiendo create_obsidian_note; (2) capa registro estructurado best-effort que hace POST al service osint_db (DuckDB single-writer) en /api/scan. Si el service esta caido o el endpoint no existe (404), degrada a solo-nota con register_warning sin fallar. No lanza: devuelve dict de estado." +tags: [recon, osint, cybersecurity, obsidian, sink] +uses_functions: [create_obsidian_note_py_obsidian] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/cybersecurity/save_scan_to_osint.py" +params: + - name: target + desc: "Objetivo del scan (dominio, host o IP). Define el slug de la carpeta en el vault." + - name: scan_type + desc: "Tipo de scan (whois|rdap|dns|nmap|traceroute|ping). Texto libre que se sanea a slug seguro para nombre de archivo y tags." + - name: raw + desc: "Salida cruda del scan (texto). Se embebe en un bloque de codigo en la nota; si supera ~200KB se trunca dejando una marca." + - name: summary + desc: "dict opcional con campos resumidos del scan (registrar, ips, puertos, rtt...). Se anade al frontmatter de la nota y se envia al registro estructurado. None -> {}." + - name: vault_dir + desc: "Raiz del vault OSINT. Se expande ~. Default ~/Obsidian/osint." + - name: service_url + desc: "Base del service osint_db (FastAPI + DuckDB). Default http://127.0.0.1:8771. Se le concatena /api/scan." + - name: tool + desc: "Nombre de la herramienta usada (nmap, dig, whois...). Si None usa el scan_type saneado." +output: "dict de estado. Caso ok: {status:'ok', target, slug, scan_type, note_path (rel al vault), note_abs (ruta absoluta), registered (bool: si el POST a osint_db tuvo exito), register_warning (str|None: motivo si el registro DuckDB fallo), scan_id (str|None: id devuelto por el service)}. Caso error (solo si falla la escritura critica de la nota): {status:'error', error: str}." +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity.save_scan_to_osint import save_scan_to_osint + +res = save_scan_to_osint( + "example.com", + "whois", + "Domain: EXAMPLE.COM\nRegistrar: X", + summary={"registrar": "X"}, +) +print(res["note_path"]) # dominios/example.com/recon/whois-YYYYMMDD-HHMM.md +print(res["registered"]) # True si osint_db esta vivo y expone POST /api/scan, False si degrado +``` + +## Cuando usarla + +Tras cualquier escaneo de red (whois/rdap/dns/nmap/traceroute/ping), para que el +resultado quede archivado y navegable en el vault OSINT. Llamar SIEMPRE despues de +un scan. Es el sink comun del ecosistema: cualquier funcion de scan del registry +(whois_lookup, dns_records, scan_port_tcp, etc.) deberia volcar aqui su salida. + +## Gotchas + +- **Impura**: escribe en disco (el vault Obsidian) y hace una request HTTP de red. +- **overwrite=True**: un re-scan del mismo target+tipo dentro del mismo minuto pisa la + nota anterior (el timestamp del nombre de archivo tiene granularidad de minuto, + `YYYYMMDD-HHMM`). +- **Registro DuckDB best-effort**: la capa 2 depende de que el service `osint_db` este + vivo y exponga `POST /api/scan`. Si esta caido (ConnectionError) o el endpoint no + existe todavia (404), la funcion NO falla: degrada a solo-nota y devuelve + `registered=False` + `register_warning` con el motivo. `status` sigue siendo `"ok"` + porque la capa critica (la nota) se guardo. +- **Single-writer DuckDB**: la DB esta abierta por el service `osint_db`. NUNCA abrir + `osint.duckdb` directo en paralelo; el registro estructurado pasa SIEMPRE por HTTP. +- **Solo `status:"error"`** si falla la escritura de la nota (capa critica). Un fallo de + red nunca produce error. +- **Contrato del endpoint** (lo crea el service osint_db): `POST /api/scan` con JSON + `{target, target_slug, scan_type, tool, note_path, summary, scan_ts}`; respuesta 2xx, + opcionalmente `{"id": "..."}` que se devuelve como `scan_id`. + +## Notas + +Compone `create_obsidian_note_py_obsidian` (del grupo obsidian) para la capa nota y usa +`urllib.request` de stdlib para la capa de registro (sin dependencias nuevas). El +timeout HTTP es de 5s. El raw se envuelve en un bloque de codigo fenced con backticks +suficientes para no colisionar con backticks internos del propio raw. diff --git a/python/functions/cybersecurity/save_scan_to_osint.py b/python/functions/cybersecurity/save_scan_to_osint.py new file mode 100644 index 00000000..2ca2ee1a --- /dev/null +++ b/python/functions/cybersecurity/save_scan_to_osint.py @@ -0,0 +1,233 @@ +"""Sink comun: persiste el resultado de cualquier escaneo de red en el ecosistema OSINT. + +Toda funcion de scan (whois, rdap, dns, nmap, traceroute, ping) llama a esta funcion +DESPUES de ejecutarse para que el resultado quede archivado y navegable. Tiene dos capas: + +1. Capa nota (SIEMPRE, fuente de verdad): escribe una nota Markdown en el vault de + Obsidian OSINT bajo `dominios//recon/-.md` con el raw del scan + en un bloque de codigo y un frontmatter tipado. Compone create_obsidian_note del + grupo obsidian. + +2. Capa registro estructurado (best-effort): hace POST al service osint_db + (FastAPI + DuckDB single-writer) en /api/scan para indexar el scan. Si el endpoint + no existe todavia (404) o el service esta caido (ConnectionError), degrada a solo-nota + con un register_warning, SIN fallar: la nota ya quedo guardada. + +Funcion impura: escribe en disco y hace red. No lanza; devuelve un dict de estado. +""" + +import json +import os +import re +import urllib.error +import urllib.request +from datetime import datetime + +from obsidian import create_obsidian_note + +# Tipos de scan reconocidos. scan_type es texto libre pero se sanea a slug seguro. +_KNOWN_SCAN_TYPES = {"whois", "rdap", "dns", "nmap", "traceroute", "ping"} + +# Limite del raw embebido en la nota (caracteres). Por encima se trunca. +_RAW_MAX = 200_000 + + +def _slugify(value: str) -> str: + """Normaliza un texto a slug seguro: minusculas, solo [a-z0-9._-].""" + s = re.sub(r"[^a-z0-9._-]+", "-", value.strip().lower()).strip("-") + return s or "unknown" + + +def _fence(raw: str) -> str: + """Envuelve raw en un bloque de codigo fenced, evitando colisionar con ``` interiores.""" + # Elige un cercado con suficientes backticks para que el contenido no lo cierre. + longest = 0 + for run in re.findall(r"`+", raw): + longest = max(longest, len(run)) + fence = "`" * max(3, longest + 1) + return f"{fence}text\n{raw}\n{fence}" + + +def save_scan_to_osint( + target: str, + scan_type: str, + raw: str, + summary: dict | None = None, + vault_dir: str = "~/Obsidian/osint", + service_url: str = "http://127.0.0.1:8771", + tool: str | None = None, +) -> dict: + """Persiste un resultado de escaneo de red en el vault OSINT (nota + registro DuckDB). + + Args: + target: objetivo del scan (dominio, host o IP). Define el slug de la carpeta. + scan_type: tipo de scan (whois|rdap|dns|nmap|traceroute|ping); texto libre que + se saneara a slug seguro para nombres de archivo y tags. + raw: salida cruda del scan (texto). Se embebe en un bloque de codigo en la nota; + si supera ~200KB se trunca dejando una marca. + summary: dict opcional con campos resumidos del scan (registrar, ips, puertos, + rtt, etc.). Se anade al frontmatter y se envia al registro estructurado. + vault_dir: raiz del vault OSINT. Se expande ~ . Default ~/Obsidian/osint. + service_url: base del service osint_db. Default http://127.0.0.1:8771. + tool: nombre de la herramienta usada (nmap, dig, whois...). Si None, usa scan_type. + + Returns: + dict de estado. Caso ok: + {"status": "ok", "target": str, "slug": str, "scan_type": str, + "note_path": str (rel al vault), "note_abs": str (ruta absoluta), + "registered": bool, "register_warning": str | None, + "scan_id": str | None} + Caso error (solo si falla la escritura critica de la nota): + {"status": "error", "error": str} + """ + try: + scan_type_slug = _slugify(scan_type) + slug = _slugify(target) + tool_name = tool or scan_type_slug + + now = datetime.now() + ts_compact = now.strftime("%Y%m%d-%H%M") + ts_iso = now.isoformat() + + rel_path = f"dominios/{slug}/recon/{scan_type_slug}-{ts_compact}.md" + + summary = summary if isinstance(summary, dict) else {} + + # --- Capa nota (critica) --- + frontmatter = { + "tipo": "scan-red", + "scan_tipo": scan_type_slug, + "target": target, + "slug": slug, + "fecha": ts_iso, + "herramienta": tool_name, + "tags": ["scan-red", scan_type_slug, "recon"], + } + if summary: + frontmatter["summary"] = summary + + raw_body = raw if isinstance(raw, str) else str(raw) + truncated = False + if len(raw_body) > _RAW_MAX: + raw_body = raw_body[:_RAW_MAX] + truncated = True + + lines = [ + f"# {scan_type_slug} scan — {target}", + "", + f"- **Target:** {target}", + f"- **Tipo:** {scan_type_slug}", + f"- **Herramienta:** {tool_name}", + f"- **Fecha:** {ts_iso}", + ] + if summary: + lines.append("") + lines.append("## Resumen") + for k, v in summary.items(): + lines.append(f"- **{k}:** {v}") + lines.append("") + lines.append("## Salida cruda") + lines.append("") + lines.append(_fence(raw_body)) + if truncated: + lines.append("") + lines.append( + f"> Salida truncada a {_RAW_MAX} caracteres (el original era mas largo)." + ) + body = "\n".join(lines) + "\n" + + note_abs = create_obsidian_note( + os.path.expanduser(vault_dir), + rel_path, + body=body, + frontmatter=frontmatter, + overwrite=True, + ) + + # --- Capa registro estructurado (best-effort) --- + registered = False + register_warning = None + scan_id = None + + payload = { + "target": target, + "target_slug": slug, + "scan_type": scan_type_slug, + "tool": tool_name, + "note_path": rel_path, + "summary": summary, + "scan_ts": ts_iso, + } + url = service_url.rstrip("/") + "/api/scan" + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + registered = True + try: + raw_resp = resp.read().decode("utf-8") + parsed = json.loads(raw_resp) if raw_resp else {} + if isinstance(parsed, dict) and parsed.get("id") is not None: + scan_id = str(parsed["id"]) + except (ValueError, UnicodeDecodeError): + # 2xx sin body JSON: cuenta como registrado igualmente. + pass + except urllib.error.HTTPError as e: + register_warning = f"HTTP {e.code} desde {url}: {e.reason}" + except urllib.error.URLError as e: + register_warning = f"service osint_db inaccesible en {url}: {e.reason}" + except Exception as e: # noqa: BLE001 - degradacion: red nunca rompe la nota + register_warning = f"registro fallido: {type(e).__name__}: {e}" + + return { + "status": "ok", + "target": target, + "slug": slug, + "scan_type": scan_type_slug, + "note_path": rel_path, + "note_abs": note_abs, + "registered": registered, + "register_warning": register_warning, + "scan_id": scan_id, + } + except Exception as e: # noqa: BLE001 - contrato: nunca lanzar + return {"status": "error", "error": f"{type(e).__name__}: {e}"} + + +if __name__ == "__main__": + import tempfile + + tmp_vault = tempfile.mkdtemp() + # service_url apunta a un puerto muerto para ejercitar la degradacion graceful. + result = save_scan_to_osint( + "example.com", + "whois", + "Domain: EXAMPLE.COM\nRegistrar: X", + summary={"registrar": "X"}, + vault_dir=tmp_vault, + service_url="http://127.0.0.1:1", + ) + + assert result["status"] == "ok", result + assert result["slug"] == "example.com", result + assert result["scan_type"] == "whois", result + assert result["note_path"] == result["note_path"], result + assert os.path.isfile(result["note_abs"]), result + assert result["registered"] is False, result + assert result["register_warning"], result + assert result["scan_id"] is None, result + + content = open(result["note_abs"], encoding="utf-8").read() + assert "Registrar: X" in content, content + assert "scan-red" in content, content + + print("save_scan_to_osint smoke OK") + print(f" note_path: {result['note_path']}") + print(f" note_abs: {result['note_abs']}") + print(f" registered: {result['registered']}") + print(f" register_warning: {result['register_warning']}") diff --git a/python/functions/cybersecurity/save_scan_to_osint_test.py b/python/functions/cybersecurity/save_scan_to_osint_test.py new file mode 100644 index 00000000..9a729223 --- /dev/null +++ b/python/functions/cybersecurity/save_scan_to_osint_test.py @@ -0,0 +1,171 @@ +"""Tests para save_scan_to_osint — sink OSINT, SIN red ni service real. + +La capa nota (create_obsidian_note) se ejercita de verdad escribiendo en un +``tmp_path`` de pytest (NUNCA en el vault del usuario). La unica dependencia de +red es el POST al service osint_db, que el codigo hace con +``urllib.request.urlopen``: se monkeypatchea esa funcion en el namespace del +modulo para no tocar 127.0.0.1:8771. +""" + +import importlib +import io +import json +import os +import sys +import urllib.error + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from cybersecurity import save_scan_to_osint + +# El modulo se importa via importlib para poder parchear sus globals +# (urllib.request.urlopen, alias urllib.request dentro del modulo). +mod = importlib.import_module("cybersecurity.save_scan_to_osint") + + +class _FakeResponse: + """Stub de la respuesta de urlopen usable como context manager.""" + + def __init__(self, body: bytes): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._body + + +def _patch_urlopen(monkeypatch, handler): + """Reemplaza urllib.request.urlopen (el que usa el modulo) por ``handler``. + + El modulo llama ``urllib.request.urlopen``; parcheamos el atributo sobre el + submodulo ``urllib.request`` que el modulo importo, asi ninguna llamada sale + a la red. + """ + monkeypatch.setattr(mod.urllib.request, "urlopen", handler) + + +def test_golden_service_ok_nota_y_registro(monkeypatch, tmp_path): + """Service responde 200/JSON con id: nota escrita en disco + registered True.""" + captured = {} + + def fake_urlopen(req, timeout=None): + # Capturamos el payload enviado para validar que va el target/scan_type. + captured["url"] = req.full_url + captured["payload"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse(json.dumps({"id": "scan-123"}).encode("utf-8")) + + _patch_urlopen(monkeypatch, fake_urlopen) + + raw = "Domain Name: EXAMPLE.COM\nRegistrar: Acme Registrar\n" + result = save_scan_to_osint( + "example.com", + "whois", + raw, + summary={"registrar": "Acme Registrar"}, + vault_dir=str(tmp_path), + ) + + assert result["status"] == "ok" + assert result["registered"] is True + assert result["register_warning"] is None + assert result["scan_id"] == "scan-123" + + # La nota existe de verdad en disco (capa critica). + note_abs = result["note_abs"] + assert os.path.exists(note_abs) + assert os.path.isfile(note_abs) + content = open(note_abs, encoding="utf-8").read() + # El raw del scan quedo embebido en la nota. + assert "Registrar: Acme Registrar" in content + assert "scan-red" in content + + # El POST llevo el target y el scan_type saneado. + assert captured["payload"]["target"] == "example.com" + assert captured["payload"]["scan_type"] == "whois" + assert captured["url"].endswith("/api/scan") + + +def test_degradacion_service_caido_urlerror(monkeypatch, tmp_path): + """Service inaccesible (URLError): la nota sigue existiendo, registered False, no lanza.""" + + def boom(req, timeout=None): + raise urllib.error.URLError("Connection refused") + + _patch_urlopen(monkeypatch, boom) + + raw = "Domain Name: DOWN.TEST\nRegistrar: Nobody\n" + # No debe lanzar pese al fallo de red. + result = save_scan_to_osint( + "down.test", + "whois", + raw, + vault_dir=str(tmp_path), + ) + + assert result["status"] == "ok" # contrato: nunca status error por fallo de red + assert result["registered"] is False + assert result["scan_id"] is None + assert result["register_warning"] # hay aviso del fallo de registro + + # La nota sobrevive: la capa nota es critica e independiente del service. + assert os.path.exists(result["note_abs"]) + assert "Registrar: Nobody" in open(result["note_abs"], encoding="utf-8").read() + + +def test_degradacion_service_404_httperror(monkeypatch, tmp_path): + """Endpoint no existe (HTTP 404): degrada a solo-nota, registered False, no lanza.""" + + def four_oh_four(req, timeout=None): + raise urllib.error.HTTPError( + url=req.full_url, code=404, msg="Not Found", hdrs=None, fp=io.BytesIO(b"") + ) + + _patch_urlopen(monkeypatch, four_oh_four) + + result = save_scan_to_osint( + "missing.test", + "dns", + "A missing.test 1.2.3.4\n", + vault_dir=str(tmp_path), + ) + + assert result["status"] == "ok" + assert result["registered"] is False + assert result["scan_id"] is None + assert "404" in result["register_warning"] + assert os.path.exists(result["note_abs"]) + + +def test_slug_del_target_se_normaliza_en_ruta(monkeypatch, tmp_path): + """'Google.COM' se normaliza a dominios/google.com/recon/... (slugify real).""" + + # No nos importa el service aqui: que falle limpio. + def boom(req, timeout=None): + raise urllib.error.URLError("no service") + + _patch_urlopen(monkeypatch, boom) + + result = save_scan_to_osint( + "Google.COM", + "Whois", + "Domain Name: GOOGLE.COM\n", + vault_dir=str(tmp_path), + ) + + assert result["status"] == "ok" + assert result["slug"] == "google.com" + # scan_type tambien se sanea a slug en minusculas. + assert result["scan_type"] == "whois" + # La ruta relativa refleja el slug normalizado. + assert result["note_path"].startswith("dominios/google.com/recon/whois-") + assert result["note_path"].endswith(".md") + + # En disco la carpeta es dominios/google.com/recon/ dentro del tmp vault. + expected_dir = os.path.join(str(tmp_path), "dominios", "google.com", "recon") + assert os.path.isdir(expected_dir) + assert os.path.exists(result["note_abs"]) diff --git a/python/functions/cybersecurity/scan_tcp_ports.md b/python/functions/cybersecurity/scan_tcp_ports.md new file mode 100644 index 00000000..7cce6364 --- /dev/null +++ b/python/functions/cybersecurity/scan_tcp_ports.md @@ -0,0 +1,99 @@ +--- +name: scan_tcp_ports +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: impure +signature: "def scan_tcp_ports(host: str, ports: str | list[int] = 'common', timeout_s: float = 1.0, workers: int = 100) -> dict" +description: "Connect-scan TCP concurrente de un host sobre una lista o rango de puertos usando SOLO stdlib (socket + ThreadPoolExecutor). NO requiere nmap ni sudo: es un connect-scan simple (full handshake) que clasifica cada puerto en open/closed/filtered y los corre en paralelo con threads. Complementa a nmap_scan para escaneo rapido en Python puro; NO detecta version de servicio. Acepta ports como lista de ints, preset 'common', rango '1-1024' o CSV '22,80,443'. NO lanza: devuelve dict status ok/error con campo raw legible para evidencia OSINT." +tags: [recon, cybersecurity, port-scan, tcp, network] +params: + - name: host + desc: "Hostname o IP objetivo a escanear (ej. 'scanme.nmap.org', '127.0.0.1', '192.168.1.10'). Se resuelve a IP con socket.gethostbyname; si no resuelve devuelve status error. Vacio devuelve status error." + - name: ports + desc: "Especificacion de puertos. Cuatro formas: lista de ints [22,80,443]; string preset 'common' (~30 puertos comunes: 21,22,23,25,53,80,110,135,139,143,443,445,993,995,3306,3389,5432,5900,6379,8080,8443,27017... default); string rango '1-1024'; string CSV '22,80,443' (admite rangos mezclados '22,80,8000-8010'). Se normaliza a lista ordenada de ints unicos en 1..65535. Spec invalida devuelve status error." + - name: timeout_s + desc: "Timeout por conexion TCP en segundos (float). Default 1.0. Valor bajo en redes lentas puede marcar puertos realmente abiertos como filtered." + - name: workers + desc: "Numero de hilos concurrentes del ThreadPoolExecutor. Default 100. Se acota internamente a >=1 y al numero de puertos a escanear. Valores muy altos pueden saturar descriptores de archivo o la red local." +output: "dict de estado. ok: {status:'ok', host, ip (resuelta), ports_scanned:int, open:[int] (ordenada), closed_count:int, filtered_count:int, results:[{port:int, state:'open'|'closed'|'filtered'}] (ordenado por puerto), raw:str (bloque PORT/STATE legible con open+filtered, omite closed)}. error (host no resuelve, spec invalida, host vacio): {status:'error', error:str, host}. Nunca lanza excepciones." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: true +tests: ["test_parse_ports_common", "test_parse_ports_rango", "test_parse_ports_csv", "test_parse_ports_lista", "test_scan_localhost_puerto_abierto", "test_scan_host_no_resuelve_error", "test_scan_host_vacio_error", "test_scan_spec_invalida_error"] +test_file_path: "python/functions/cybersecurity/scan_tcp_ports_test.py" +file_path: "python/functions/cybersecurity/scan_tcp_ports.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity import scan_tcp_ports + +# 1) Puertos comunes contra el host oficial de pruebas de nmap (legal escanear). +res = scan_tcp_ports("scanme.nmap.org", ports="common", timeout_s=1.0) +if res["status"] == "ok": + print(res["ip"], "abiertos:", res["open"]) # ej. 45.33.32.156 abiertos: [22, 80] + print(res["raw"]) # bloque PORT/STATE para el vault +else: + print("error:", res["error"]) + +# 2) Rango de puertos concreto en localhost. +res = scan_tcp_ports("127.0.0.1", ports="1-1024", timeout_s=0.3, workers=200) +print(res["open"]) + +# 3) Lista explicita de puertos. +res = scan_tcp_ports("192.168.1.10", ports=[22, 80, 443, 8080]) +``` + +Invocacion directa por el registry: + +```bash +# Via MCP (preferido): +# mcp__registry__fn_run id="scan_tcp_ports_py_cybersecurity" args=["scanme.nmap.org"] +# Via CLI: +./fn run scan_tcp_ports scanme.nmap.org +``` + +## Cuando usarla + +Usala cuando quieras saber rapidamente que puertos TCP estan abiertos en UN host +sin depender de nmap ni de sudo: escaneo en Python puro, scriptable y headless. +Ideal en entornos donde no puedes instalar nmap o quieres un sondeo ligero de la +superficie expuesta (un puñado de puertos o un rango pequeño) antes de pasar a +herramientas mas pesadas. + +A diferencia de `nmap_scan_py_cybersecurity`: este NO da version ni nombre del +servicio, solo el estado del puerto (open/closed/filtered). Si necesitas +deteccion de version (-sV), scripts NSE, OS detection, UDP o barrido de subred, +usa `nmap_scan`. Para un check rapido "que puertos responden" en 1 host, esta es +mas directa. + +## Gotchas + +- Funcion impura: abre conexiones TCP reales (full three-way handshake). Es un + connect-scan, por lo que NO es sigiloso: queda en los logs del objetivo y es + facilmente detectable por IDS/firewalls. Sin sudo no hace SYN-scan (half-open). +- LEGAL: escanear puertos de hosts de terceros sin autorizacion puede ser delito. + Escanea solo objetivos propios o con permiso explicito. `scanme.nmap.org` es el + host oficial de pruebas de nmap (legal escanear). +- `timeout_s` bajo en redes lentas o con alta latencia puede marcar puertos + realmente ABIERTOS como `filtered` (la conexion no completa a tiempo). Sube + `timeout_s` si dudas de los resultados; bajalo para escanear rangos grandes mas + rapido a costa de falsos filtered. +- `workers` muy alto (miles) puede agotar descriptores de archivo del proceso o + saturar la red local / el objetivo. Se acota internamente al numero de puertos, + pero un rango grande con workers altos sigue siendo agresivo. +- Distincion de estados: `open` = connect exito; `closed` = RST / connection + refused (host vivo, puerto cerrado); `filtered` = timeout / inalcanzable + (probable firewall que descarta el paquete). Un host detras de firewall + drop-all puede devolver TODO filtered aunque tenga servicios. +- Solo IPv4: usa `socket.gethostbyname` (resuelve a A record). Para IPv6 usar otra + ruta. No lanza: revisa siempre `res["status"]` antes de leer `open`/`results`. diff --git a/python/functions/cybersecurity/scan_tcp_ports.py b/python/functions/cybersecurity/scan_tcp_ports.py new file mode 100644 index 00000000..6b00510c --- /dev/null +++ b/python/functions/cybersecurity/scan_tcp_ports.py @@ -0,0 +1,240 @@ +"""Connect-scan TCP concurrente de un host usando SOLO stdlib (socket + threads). + +Funcion IMPURA: abre conexiones TCP (connect) contra una lista o rango de +puertos de un host, en paralelo con un ThreadPoolExecutor. NO requiere nmap ni +sudo: es un connect-scan simple (full three-way handshake), por lo que no es +sigiloso pero funciona desde cualquier entorno Python sin privilegios. + +Complementa a `nmap_scan` cuando no se quiere/puede usar nmap o se busca un +escaneo rapido en Python puro. A diferencia de nmap_scan, NO detecta version de +servicio: solo reporta el estado del puerto (open/closed/filtered). + +NO lanza excepciones: devuelve un dict con `status` "ok" o "error" y un campo +`raw` legible pensado para guardar como evidencia OSINT. Solo escanear hosts +autorizados/propios. +""" + +import socket +from concurrent.futures import ThreadPoolExecutor, as_completed + +# ~30 puertos TCP comunes para el preset "common". +_COMMON_PORTS = [ + 21, 22, 23, 25, 53, 80, 110, 111, 135, 139, 143, 443, 445, 993, 995, + 1723, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9200, 11211, 27017, + 1433, 2049, 5060, 8000, +] + + +def _parse_ports(ports) -> list[int]: + """Normaliza la especificacion de puertos a una lista ordenada de ints unicos. + + Acepta cuatro formas: + - lista de ints: ``[22, 80, 443]`` + - string preset: ``"common"`` (~30 puertos comunes) + - string rango: ``"1-1024"`` + - string CSV: ``"22,80,443"`` (admite tambien rangos mezclados: + ``"22,80,8000-8010"``) + + Args: + ports: lista de ints o string en una de las formas anteriores. + + Returns: + Lista ordenada de ints unicos en el rango valido 1..65535. + + Raises: + ValueError: si el formato es invalido o no quedan puertos validos. + (Uso interno; `scan_tcp_ports` lo captura y devuelve status error.) + """ + if isinstance(ports, (list, tuple, set)): + out = set() + for p in ports: + pi = int(p) + if 1 <= pi <= 65535: + out.add(pi) + if not out: + raise ValueError("lista de puertos vacia o sin puertos validos (1..65535)") + return sorted(out) + + if not isinstance(ports, str): + raise ValueError(f"ports debe ser str o lista de ints, no {type(ports).__name__}") + + spec = ports.strip().lower() + if not spec: + raise ValueError("spec de puertos vacia") + + if spec == "common": + return sorted(set(_COMMON_PORTS)) + + out = set() + for chunk in spec.split(","): + chunk = chunk.strip() + if not chunk: + continue + if "-" in chunk: + lo_s, hi_s = chunk.split("-", 1) + lo, hi = int(lo_s), int(hi_s) + if lo > hi: + lo, hi = hi, lo + for pi in range(lo, hi + 1): + if 1 <= pi <= 65535: + out.add(pi) + else: + pi = int(chunk) + if 1 <= pi <= 65535: + out.add(pi) + if not out: + raise ValueError(f"no se obtuvieron puertos validos de '{ports}'") + return sorted(out) + + +def _probe_port(ip: str, port: int, timeout_s: float) -> str: + """Sondea un puerto TCP via connect y clasifica su estado. + + Returns: + "open" -> connect_ex == 0 (handshake completo). + "closed" -> RST / ConnectionRefused. + "filtered" -> timeout o host inalcanzable (probable firewall). + """ + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout_s) + rc = sock.connect_ex((ip, port)) + if rc == 0: + return "open" + # ECONNREFUSED (111 Linux / 10061 Win) -> puerto cerrado pero host vivo. + if rc in (111, 10061): + return "closed" + return "filtered" + except (socket.timeout, TimeoutError): + return "filtered" + except (ConnectionRefusedError, OSError): + return "closed" + + +def scan_tcp_ports( + host: str, + ports="common", + timeout_s: float = 1.0, + workers: int = 100, +) -> dict: + """Escanea puertos TCP de un host por connect-scan concurrente (stdlib). + + Resuelve `host` a IP, parsea la spec de puertos y lanza un connect TCP por + cada puerto en paralelo (ThreadPoolExecutor). Clasifica cada puerto como + open / closed / filtered y agrega los resultados. + + Args: + host: Hostname o IP objetivo (ej. "scanme.nmap.org", "127.0.0.1"). Se + resuelve con socket.gethostbyname; si no resuelve, status error. + ports: Especificacion de puertos. Acepta lista de ints ([22, 80, 443]), + string preset "common" (~30 puertos comunes, default), string rango + "1-1024", o string CSV "22,80,443" (con rangos mezclados + "22,80,8000-8010"). Spec invalida devuelve status error. + timeout_s: Timeout por conexion TCP en segundos. Default 1.0. Bajo en + redes lentas puede marcar puertos abiertos como filtered. + workers: Numero de hilos concurrentes. Default 100. Se acota a >=1 y al + numero de puertos a escanear. Valores muy altos pueden saturar + descriptores de archivo o la red. + + Returns: + Dict con status "ok" o "error". Nunca lanza. + ok:: + + { + "status": "ok", + "host": , + "ip": , + "ports_scanned": , + "open": [, ...], # ordenada + "closed_count": , + "filtered_count": , + "results": [{"port": int, "state": str}, ...], # ordenado por puerto + "raw": , + } + + error (host no resuelve, spec invalida):: + + {"status": "error", "error": , "host": } + """ + if not host or not host.strip(): + return {"status": "error", "error": "scan_tcp_ports: host vacio", "host": host} + + host = host.strip() + + # Parsear puertos. + try: + port_list = _parse_ports(ports) + except (ValueError, TypeError) as exc: + return { + "status": "error", + "error": f"scan_tcp_ports: spec de puertos invalida: {exc}", + "host": host, + } + + # Resolver host a IP. + try: + ip = socket.gethostbyname(host) + except socket.gaierror as exc: + return { + "status": "error", + "error": f"scan_tcp_ports: no se pudo resolver host '{host}': {exc}", + "host": host, + } + + n_workers = max(1, min(int(workers), len(port_list))) + + # Sondeo concurrente. + states: dict[int, str] = {} + with ThreadPoolExecutor(max_workers=n_workers) as pool: + futures = { + pool.submit(_probe_port, ip, port, timeout_s): port + for port in port_list + } + for fut in as_completed(futures): + port = futures[fut] + try: + states[port] = fut.result() + except Exception: # noqa: BLE001 - un probe nunca debe tumbar el scan + states[port] = "filtered" + + results = [{"port": p, "state": states[p]} for p in sorted(states)] + open_ports = sorted(p for p, st in states.items() if st == "open") + closed_count = sum(1 for st in states.values() if st == "closed") + filtered_count = sum(1 for st in states.values() if st == "filtered") + + # Bloque legible para evidencia (solo open/filtered; los closed se omiten + # para que el raw sea util sin ahogarlo en cientos de "closed"). + raw_lines = ["PORT STATE"] + for r in results: + if r["state"] != "closed": + raw_lines.append(f"{r['port']:<5}/tcp {r['state']}") + raw = "\n".join(raw_lines) + + return { + "status": "ok", + "host": host, + "ip": ip, + "ports_scanned": len(port_list), + "open": open_ports, + "closed_count": closed_count, + "filtered_count": filtered_count, + "results": results, + "raw": raw, + } + + +if __name__ == "__main__": + # Smoke: scan rapido contra el host oficial de pruebas de nmap (legal escanear). + # Tolera fallo de red sin romper. + try: + result = scan_tcp_ports("scanme.nmap.org", ports="common", timeout_s=2.0) + print(result["status"]) + if result["status"] == "ok": + print(f"[ok] {result['host']} ({result['ip']}) " + f"escaneados={result['ports_scanned']} abiertos={result['open']}") + print("--- raw ---") + print(result["raw"]) + else: + print("error:", result.get("error")) + except Exception as exc: # noqa: BLE001 - smoke nunca debe romper + print("smoke fallo (tolerado):", exc) diff --git a/python/functions/cybersecurity/scan_tcp_ports_test.py b/python/functions/cybersecurity/scan_tcp_ports_test.py new file mode 100644 index 00000000..1f18a21b --- /dev/null +++ b/python/functions/cybersecurity/scan_tcp_ports_test.py @@ -0,0 +1,106 @@ +"""Tests para scan_tcp_ports (connect-scan TCP stdlib, estilo dict sin excepciones). + +SIN red externa: el parser `_parse_ports` se prueba de forma pura y el smoke de +escaneo se hace contra `127.0.0.1` con un socket listen efimero abierto por el +propio test (y un puerto cerrado alto), de modo que el test es determinista y +no depende de internet. +""" + +import os +import socket +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from scan_tcp_ports import _parse_ports, scan_tcp_ports + + +# --- 1. _parse_ports: las cuatro formas -------------------------------------- + +def test_parse_ports_common(): + """El preset 'common' devuelve la lista de puertos comunes, ordenada y unica.""" + ports = _parse_ports("common") + assert isinstance(ports, list) + assert ports == sorted(ports) + assert len(ports) == len(set(ports)) + # Puertos canonicos que deben estar en el preset. + for p in (22, 80, 443, 3306, 3389): + assert p in ports + + +def test_parse_ports_rango(): + """Un rango 'lo-hi' se expande a todos los enteros inclusive, ordenados.""" + assert _parse_ports("1-10") == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + # Rango invertido se normaliza. + assert _parse_ports("10-8") == [8, 9, 10] + + +def test_parse_ports_csv(): + """Un CSV se parsea a ints unicos ordenados; admite rangos mezclados.""" + assert _parse_ports("22,80,443") == [22, 80, 443] + # Duplicados se colapsan, rango mezclado se expande. + assert _parse_ports("80,80,22,8000-8002") == [22, 80, 8000, 8001, 8002] + + +def test_parse_ports_lista(): + """Una lista de ints se normaliza a ordenada/unica, filtrando fuera de rango.""" + assert _parse_ports([443, 22, 80, 22]) == [22, 80, 443] + # Puertos fuera de 1..65535 se descartan. + assert _parse_ports([22, 0, 70000, 80]) == [22, 80] + + +# --- 2. scan_tcp_ports: smoke determinista en localhost ---------------------- + +def test_scan_localhost_puerto_abierto(): + """Abre un listen efimero en 127.0.0.1 y verifica open + un alto closed/filtered.""" + 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(1) + open_port = srv.getsockname()[1] + # Un puerto alto que casi seguro esta cerrado en localhost. + closed_port = 1 + try: + res = scan_tcp_ports( + "127.0.0.1", + ports=[open_port, closed_port], + timeout_s=1.0, + ) + assert res["status"] == "ok" + assert res["ip"] == "127.0.0.1" + assert res["ports_scanned"] == 2 + assert open_port in res["open"] + # El puerto cerrado no debe figurar como abierto. + assert open_port != closed_port + assert closed_port not in res["open"] + # results cubre ambos puertos con un state valido. + states = {r["port"]: r["state"] for r in res["results"]} + assert states[open_port] == "open" + assert states[closed_port] in ("closed", "filtered") + assert "raw" in res and isinstance(res["raw"], str) + finally: + srv.close() + + +# --- 3. Error paths: siempre dict, nunca excepcion --------------------------- + +def test_scan_host_no_resuelve_error(): + """Un hostname que no resuelve devuelve status error sin lanzar.""" + res = scan_tcp_ports("nohost.invalid.tld.example", ports=[80], timeout_s=0.5) + assert res["status"] == "error" + assert "resolver" in res["error"] or "resolv" in res["error"].lower() + assert res["host"] == "nohost.invalid.tld.example" + + +def test_scan_host_vacio_error(): + """Host vacio devuelve status error.""" + res = scan_tcp_ports("", ports="common") + assert res["status"] == "error" + assert "vacio" in res["error"] + + +def test_scan_spec_invalida_error(): + """Una spec de puertos invalida devuelve status error sin tocar la red.""" + res = scan_tcp_ports("127.0.0.1", ports="no-son-puertos") + assert res["status"] == "error" + assert "puertos" in res["error"] or "spec" in res["error"] diff --git a/python/functions/cybersecurity/traceroute_host.md b/python/functions/cybersecurity/traceroute_host.md new file mode 100644 index 00000000..c2e63739 --- /dev/null +++ b/python/functions/cybersecurity/traceroute_host.md @@ -0,0 +1,74 @@ +--- +name: traceroute_host +kind: function +lang: py +domain: cybersecurity +version: "1.0.0" +purity: impure +signature: "def traceroute_host(host: str, max_hops: int = 30, timeout_s: int = 60) -> dict" +description: "Traza la ruta de red hacia un host ejecutando `traceroute -m -w 2 ` (Linux) por subprocess y parseando best-effort cada hop: numero de salto, hosts (nombre + IP) y rtt detectados por regex. Un hop sin respuesta ('* * *') tiene hosts vacio. Devuelve dict de estado sin lanzar; `raw` siempre presente con el stdout." +tags: [recon, traceroute, cybersecurity, network, route] +params: + - name: host + desc: "Hostname o IP destino, ej. google.com o 1.1.1.1. Vacio devuelve status error." + - name: max_hops + desc: "Maximo numero de saltos a sondear (traceroute -m). Default 30." + - name: timeout_s + desc: "Timeout duro del subprocess en segundos (traceroute puede tardar si hay hops que no responden). Default 60." +output: "dict de estado. En exito {status:'ok', host, hops:[{hop:int, hosts:[{name:str, ip:str, rtt_ms:[float,...]}]}], raw:str}; un hop sin respuesta ('* * *') tiene hosts=[]. En fallo {status:'error', error:str, host, raw:str}." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_py_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/cybersecurity/traceroute_host.py" +--- + +## Ejemplo + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity import traceroute_host + +res = traceroute_host("1.1.1.1", max_hops=20, timeout_s=40) +print(res["status"]) # "ok" +print(len(res["hops"])) # numero de saltos detectados +for hop in res["hops"][:5]: + ips = [h["ip"] for h in hop["hosts"] if h["ip"]] + print(hop["hop"], ips or "* * *") +print(res["raw"]) # stdout crudo para el vault OSINT +``` + +## Cuando usarla + +Usala para mapear el camino de red (saltos intermedios, ASNs/proveedores por las +IPs) hacia un objetivo durante el recon de infraestructura, despues de confirmar +con `ping_host` que responde. Cada hop con su IP ayuda a inferir la topologia y +el alojamiento del objetivo. Guarda `raw` como evidencia en la nota OSINT. + +## Gotchas + +- Funcion impura: envia trafico de red (UDP/ICMP segun la implementacion de + traceroute) a multiples saltos. No determinista (rutas cambian, latencia + varia). +- Linux-only: usa la sintaxis `traceroute -m N -w 2` del paquete `traceroute`. + En otras plataformas (`tracert` en Windows, traceroute BSD) los flags y el + formato difieren y el parseo fallaria. +- **Hops filtrados son normales**, no error: firewalls/routers que no decrementan + TTL o no devuelven ICMP TTL-exceeded aparecen como "* * *" → `hosts: []`. Una + traza incompleta o que no llega al destino es esperable, sigue `status:"ok"`. +- Parseo best-effort por regex: captura numero de hop + IPs detectadas; los rtt + de la linea se asocian a todos los hosts del hop (no se separa por sonda). + Para fidelidad total mira `raw`. +- Requiere el binario `traceroute` en PATH. Si falta, devuelve + `{"status":"error",...}` (no lanza). Puede necesitar privilegios segun el modo + (raw sockets); si no los tiene, los hops pueden salir incompletos. +- Nunca lanza: errores en `status`. Si la traza tarda mas de `timeout_s`, es + `status:"error"` con el stdout parcial en `raw`. +- Puede ser lento: con hops que no responden, traceroute espera el `-w 2` por + sonda; ajusta `timeout_s` en consecuencia. diff --git a/python/functions/cybersecurity/traceroute_host.py b/python/functions/cybersecurity/traceroute_host.py new file mode 100644 index 00000000..1453c13f --- /dev/null +++ b/python/functions/cybersecurity/traceroute_host.py @@ -0,0 +1,143 @@ +"""Trazado de la ruta de red hacia un host via el binario `traceroute` (Linux). + +Funcion IMPURA: ejecuta `traceroute -m -w 2 ` como subprocess +y parsea best-effort cada hop: numero de salto, hosts (nombre + IP) y los rtt +detectados. Un hop sin respuesta ("* * *") se representa con `hosts` vacio. No +busca un parseo perfecto: captura el numero de hop y las IPs por regex. Nunca +lanza; devuelve dict de estado con `raw` siempre presente. +""" + +import re +import subprocess + +# Inicio de una linea de hop: numero de salto al principio. +_HOP_LINE_RE = re.compile(r"^\s*(\d+)\s+(.*)$") +# IPv4 entre parentesis o suelta. +_IP_RE = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b") +# "nombre (ip)" o "ip" +_HOST_PAREN_RE = re.compile(r"([\w.\-]+)\s+\((\d{1,3}(?:\.\d{1,3}){3})\)") +# rtt en milisegundos, p.ej "1.234 ms" +_RTT_RE = re.compile(r"([\d.]+)\s*ms") + + +def _parse_hop(hop_num: int, rest: str) -> dict: + """Parsea best-effort el cuerpo de una linea de hop.""" + hosts: list[dict] = [] + + # Caso sin respuesta: solo asteriscos. + if rest.replace("*", "").strip() == "": + return {"hop": hop_num, "hosts": []} + + # rtt globales de la linea (se asocian al/los host(s) detectados). + rtts = [float(x) for x in _RTT_RE.findall(rest)] + + # Hosts con formato "nombre (ip)". + paren_matches = _HOST_PAREN_RE.findall(rest) + seen_ips: set[str] = set() + for name, ip in paren_matches: + seen_ips.add(ip) + hosts.append({"name": name, "ip": ip, "rtt_ms": rtts}) + + # IPs sueltas no capturadas por el patron "nombre (ip)". + for ip in _IP_RE.findall(rest): + if ip not in seen_ips: + seen_ips.add(ip) + hosts.append({"name": "", "ip": ip, "rtt_ms": rtts}) + + # Si no detectamos ningun host pero la linea tiene contenido (raro), + # dejamos un host placeholder con el texto en name para no perder info. + if not hosts and rest.strip() and rest.strip() != "*": + hosts.append({"name": rest.strip(), "ip": "", "rtt_ms": rtts}) + + return {"hop": hop_num, "hosts": hosts} + + +def traceroute_host(host: str, max_hops: int = 30, timeout_s: int = 60) -> dict: + """Traza la ruta de red hacia un host y parsea los hops best-effort. + + Args: + host: Hostname o IP destino (ej. ``"google.com"`` o ``"1.1.1.1"``). + max_hops: Maximo numero de saltos a sondear (`traceroute -m`). + timeout_s: Timeout duro del subprocess en segundos. + + Returns: + Dict de estado. En exito:: + + { + "status": "ok", + "host": , + "hops": [ + {"hop": 1, "hosts": [{"name": str, "ip": str, "rtt_ms": [float, ...]}]}, + {"hop": 2, "hosts": []}, # "* * *" sin respuesta + ... + ], + "raw": , + } + + En fallo (binario ausente, host vacio, timeout duro):: + + {"status": "error", "error": , "host": , "raw": } + """ + if not host or not host.strip(): + return {"status": "error", "error": "traceroute_host: host vacio", "host": host, "raw": ""} + + host = host.strip() + + try: + proc = subprocess.run( + ["traceroute", "-m", str(max_hops), "-w", "2", host], + capture_output=True, + text=True, + timeout=float(timeout_s), + ) + except FileNotFoundError: + return { + "status": "error", + "error": "traceroute_host: binario `traceroute` no encontrado en PATH", + "host": host, + "raw": "", + } + except subprocess.TimeoutExpired as exc: + partial = exc.stdout or "" + if isinstance(partial, bytes): + partial = partial.decode(errors="replace") + return { + "status": "error", + "error": f"traceroute_host: timeout duro del subprocess tras {timeout_s}s", + "host": host, + "raw": partial, + } + + raw = proc.stdout or "" + hops: list[dict] = [] + + for line in raw.splitlines(): + m = _HOP_LINE_RE.match(line) + if not m: + continue # cabecera "traceroute to ..." u otras lineas no-hop + hop_num = int(m.group(1)) + hops.append(_parse_hop(hop_num, m.group(2))) + + return { + "status": "ok", + "host": host, + "hops": hops, + "raw": raw, + } + + +if __name__ == "__main__": + try: + result = traceroute_host("1.1.1.1", max_hops=15, timeout_s=40) + print(result["status"]) + if result["status"] == "ok": + print(f"hops detectados: {len(result['hops'])}") + for hop in result["hops"][:5]: + ips = [h["ip"] for h in hop["hosts"] if h["ip"]] + print(f" hop {hop['hop']}: {ips or '* * *'}") + print("--- raw ---") + print(result["raw"]) + else: + print("error:", result.get("error")) + except Exception as exc: # smoke: tolera cualquier fallo de red sin romper + print("smoke fallo (tolerado):", exc) diff --git a/python/functions/cybersecurity/traceroute_host_test.py b/python/functions/cybersecurity/traceroute_host_test.py new file mode 100644 index 00000000..1fe2d22d --- /dev/null +++ b/python/functions/cybersecurity/traceroute_host_test.py @@ -0,0 +1,103 @@ +"""Tests para traceroute_host (CLI `traceroute`, estilo dict sin excepciones). + +Sin red: se monkeypatchea ``subprocess.run`` en el namespace del modulo +``traceroute_host`` para devolver salidas fijas o lanzar excepciones. +""" + +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +import traceroute_host as tr_mod +from traceroute_host import traceroute_host + +# Salida real de traceroute: cabecera + 3 hops, uno de ellos "* * *". +RAW_OK = """\ +traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets + 1 gateway (192.168.1.1) 1.234 ms 1.111 ms 1.050 ms + 2 * * * + 3 one.one.one.one (1.1.1.1) 9.876 ms 9.500 ms 9.700 ms +""" + +# Salida con un unico hop sin respuesta. +RAW_ALL_STARS = """\ +traceroute to 10.0.0.1 (10.0.0.1), 30 hops max, 60 byte packets + 1 * * * +""" + + +class _FakeProc: + """Stand-in de CompletedProcess: solo expone stdout y returncode.""" + + def __init__(self, stdout: str, returncode: int = 0): + self.stdout = stdout + self.returncode = returncode + + +def _patch_run(monkeypatch, *, stdout=None, raises=None): + """Sustituye subprocess.run en el modulo traceroute_host.""" + + def fake_run(*args, **kwargs): + if raises is not None: + raise raises + return _FakeProc(stdout) + + monkeypatch.setattr(tr_mod.subprocess, "run", fake_run) + + +def test_golden_varios_hops(monkeypatch): + """Traceroute con varios hops: parsea numero de hop, host, IP y rtt.""" + _patch_run(monkeypatch, stdout=RAW_OK) + + result = traceroute_host("1.1.1.1") + + assert result["status"] == "ok" + assert result["host"] == "1.1.1.1" + assert result["raw"] == RAW_OK + + hops = result["hops"] + assert [h["hop"] for h in hops] == [1, 2, 3] + + # Hop 1: gateway con IP y tres rtt. + hop1 = hops[0] + assert len(hop1["hosts"]) == 1 + assert hop1["hosts"][0]["name"] == "gateway" + assert hop1["hosts"][0]["ip"] == "192.168.1.1" + assert hop1["hosts"][0]["rtt_ms"] == [1.234, 1.111, 1.050] + + # Hop 2: sin respuesta -> hosts vacio. + assert hops[1]["hosts"] == [] + + # Hop 3: destino alcanzado. + hop3 = hops[2] + assert len(hop3["hosts"]) == 1 + assert hop3["hosts"][0]["name"] == "one.one.one.one" + assert hop3["hosts"][0]["ip"] == "1.1.1.1" + assert hop3["hosts"][0]["rtt_ms"] == [9.876, 9.500, 9.700] + + +def test_edge_hop_sin_respuesta(monkeypatch): + """Un solo hop '* * *': hosts vacio para ese salto.""" + _patch_run(monkeypatch, stdout=RAW_ALL_STARS) + + result = traceroute_host("10.0.0.1") + + assert result["status"] == "ok" + assert len(result["hops"]) == 1 + assert result["hops"][0]["hop"] == 1 + assert result["hops"][0]["hosts"] == [] + + +def test_error_host_vacio(monkeypatch): + """Host en blanco: status error sin invocar subprocess.""" + + def boom(*args, **kwargs): + raise AssertionError("subprocess.run no debe llamarse con host vacio") + + monkeypatch.setattr(tr_mod.subprocess, "run", boom) + + result = traceroute_host(" ") + assert result["status"] == "error" + assert "vacio" in result["error"] diff --git a/python/functions/cybersecurity/whois_lookup.md b/python/functions/cybersecurity/whois_lookup.md index 7b6bb950..969dc042 100644 --- a/python/functions/cybersecurity/whois_lookup.md +++ b/python/functions/cybersecurity/whois_lookup.md @@ -3,25 +3,25 @@ name: whois_lookup kind: function lang: py domain: cybersecurity -version: "1.0.0" +version: "2.0.0" purity: impure -signature: "def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict" -description: "Recoleccion OSINT pasiva de datos de registro de dominio via RDAP (reemplazo moderno de WHOIS sobre HTTP/JSON). Consulta https://rdap.org/domain/ con http_get_json y normaliza registrar, fechas de creacion/expiracion/ultimo cambio, nameservers, estados y entidades. Devuelve {found: False} si el dominio no existe (404)." -tags: [osint-passive, whois, rdap, recon, cybersecurity] +signature: "def whois_lookup(target: str, timeout_s: int = 30) -> dict" +description: "Lookup WHOIS de un dominio o IP via el CLI `whois` del sistema (apt). Ejecuta `whois ` como subproceso, captura el stdout completo en raw y parsea best-effort (case-insensitive, tolerante a ausencias) registrar, registrant_country, creation_date, expiry_date, updated_date y name_servers. Devuelve siempre un dict {status: ok|error}; nunca lanza excepciones. OSINT pasivo: util para perfilado de dominios, deteccion de typosquatting/phishing y validacion de propiedad." +tags: [recon, whois, osint-passive, cybersecurity] params: - - name: dominio - desc: "Dominio a consultar, ej. organic-machine.com. Vacio lanza RuntimeError." + - name: target + desc: "Dominio (ej. google.com) o direccion IP a consultar. Vacio devuelve status error." - name: timeout_s - desc: "Segundos maximo de espera de la peticion HTTP a rdap.org (default 15.0)." -output: "dict normalizado con found (bool), registrar, creation_date, expiration_date, last_changed, nameservers (lista), status (lista), entities (lista de {handle, roles}) y raw (RDAP completo). Si el dominio no existe (HTTP 404) devuelve {found: False}." -uses_functions: ["http_get_json_py_infra"] + desc: "Segundos maximo de espera del subproceso whois (default 30)." +output: "dict. En exito: {status: 'ok', target, raw (stdout completo del whois, SIEMPRE presente), registrar, registrant_country, creation_date, expiry_date, updated_date, name_servers (lista de strings en minusculas)}. Campos no encontrados quedan None; name_servers vacio = []. Para IPs varios campos de dominio quedan None. En fallo: {status: 'error', error: str, target}." +uses_functions: [] uses_types: [] returns: [] returns_optional: false -error_type: "error_go_core" +error_type: "error_py_core" imports: [] tested: true -tests: ["test_normaliza_respuesta_rdap", "test_dominio_no_encontrado_404", "test_otro_error_http_se_propaga", "test_sin_registrar_ni_fechas", "test_dominio_vacio_lanza_error"] +tests: ["test_parsea_campos_comunes", "test_campos_ausentes_quedan_none", "test_raw_siempre_presente", "test_target_vacio_devuelve_error"] test_file_path: "python/functions/cybersecurity/whois_lookup_test.py" file_path: "python/functions/cybersecurity/whois_lookup.py" --- @@ -33,36 +33,52 @@ import sys, os sys.path.insert(0, os.path.join("python", "functions")) from cybersecurity import whois_lookup -info = whois_lookup("organic-machine.com") -if info["found"]: - print(info["registrar"]) # 'Example Registrar Inc.' - print(info["creation_date"]) # '2020-01-15T10:00:00Z' - print(info["expiration_date"]) # '2027-01-15T10:00:00Z' - print(info["nameservers"]) # ['ns1.example.net', 'ns2.example.net'] - print(info["status"]) # ['client transfer prohibited'] +info = whois_lookup("google.com") +if info["status"] == "ok": + print(info["registrar"]) # 'MarkMonitor Inc.' + print(info["registrant_country"]) # 'US' + print(info["creation_date"]) # '1997-09-15T04:00:00Z' + print(info["expiry_date"]) # '2028-09-14T04:00:00Z' + print(info["name_servers"]) # ['ns1.google.com', 'ns2.google.com', ...] + # info["raw"] tiene el texto whois completo para guardar en OSINT else: - print("dominio no registrado") + print("fallo:", info["error"]) ``` ## Cuando usarla -Usala para obtener metadatos de registro de un dominio sin depender del CLI -`whois` (no instalado): edad del dominio, fecha de expiracion (dominios a -punto de caducar), registrar y nameservers autoritativos. Util en perfilado -pasivo, deteccion de dominios recien creados (typosquatting/phishing) y -validacion de propiedad. +Usala cuando necesites los datos de registro crudos de un dominio o IP via el +CLI `whois` clasico: registrar, pais del registrante, edad del dominio (fecha +de creacion), fecha de expiracion (dominios a punto de caducar) y nameservers. +Ideal en perfilado pasivo OSINT, deteccion de dominios recien creados +(typosquatting / phishing) y para conservar el texto WHOIS completo (`raw`) +como evidencia. Para datos estructurados JSON modernos, prefiere +`rdap_lookup_py_cybersecurity`; ambas se complementan. ## Gotchas -- RDAP no esta uniformemente desplegado en todos los TLD: algunos devuelven - campos vacios o ni siquiera responden. Por eso los campos opcionales pueden - quedar `None` y `nameservers`/`status`/`entities` listas vacias. -- rdap.org actua como bootstrap y redirige al servidor RDAP autoritativo del - TLD; depende de su disponibilidad. -- El registrante (`entities` con rol distinto de `registrar`) suele estar - redactado por privacy/GDPR: casi siempre solo veras `handle` y `roles`, sin - datos personales. -- Un dominio no registrado devuelve `{"found": False}` (HTTP 404); cualquier - otro error HTTP (rate limit 429, 5xx) se propaga como `RuntimeError`. -- Las fechas se devuelven tal cual las da RDAP (ISO 8601 UTC), sin parsear a - objetos `datetime`. +- IMPURA: hace red. El servidor WHOIS del TLD/registrar puede tardar o fallar; + por eso hay `timeout_s` (default 30) y los timeouts devuelven + `{"status": "error", ...}` sin lanzar. +- El formato WHOIS **no esta estandarizado**: varia por TLD y por registrar. El + parseo es best-effort con multiples labels alternativos (`Creation Date` / + `created` / `Registered on`, etc.). Cualquier campo puede quedar `None` aunque + el dato exista bajo un label que no contemplamos — el texto completo siempre + esta en `raw`. +- Para **IPs**, muchos campos de dominio (registrar, creation_date, + name_servers) no existen y quedan `None` / `[]`; lo relevante esta en `raw` + (rango, org, abuse contact). +- Muchos registros estan redactados por privacy/GDPR: el registrante personal + raramente aparece; suele verse solo el registrar. +- `whois` a menudo escribe en stdout incluso con codigo de retorno != 0 + (avisos, rate-limit parciales). Solo se considera error duro cuando el stdout + esta totalmente vacio. +- Requiere el binario `whois` instalado (`apt install whois`). Si falta, devuelve + status error claro en vez de lanzar. + +## Capability growth log + +- v2.0.0 (2026-06-14) — reescrita sobre el CLI `whois` (antes RDAP via HTTP). Nueva + firma `(target: str, timeout_s: int = 30)`, acepta dominios e IPs, estilo dict + `{status: ok|error}` sin excepciones (alineado con el grupo recon). La variante + RDAP/JSON ahora vive en `rdap_lookup_py_cybersecurity`. diff --git a/python/functions/cybersecurity/whois_lookup.py b/python/functions/cybersecurity/whois_lookup.py index c8957df3..4f33e28a 100644 --- a/python/functions/cybersecurity/whois_lookup.py +++ b/python/functions/cybersecurity/whois_lookup.py @@ -1,114 +1,191 @@ -"""Recoleccion OSINT pasiva de datos de registro de dominio via RDAP. +"""Lookup WHOIS de un dominio o IP via el CLI `whois` del sistema. -Funcion IMPURA: consulta el servicio RDAP publico (reemplazo moderno de -WHOIS, sobre HTTP/JSON) y normaliza la respuesta. Es OSINT pasivo: no toca -al dominio objetivo, solo el directorio RDAP publico. +Funcion IMPURA: ejecuta el binario `whois` (apt) como subproceso, captura el +stdout completo y parsea best-effort los campos de registro mas comunes. Es +OSINT pasivo: no toca al objetivo, solo el directorio WHOIS publico. + +Devuelve siempre un dict (estilo del grupo recon): nunca lanza excepciones. """ -import os -import sys - -sys.path.insert( - 0, os.path.join(os.path.dirname(__file__), "..", "..", "functions") -) - -from infra.http_get_json import http_get_json # noqa: E402 +import re +import subprocess -def _events_by_action(raw: dict) -> dict: - """Indexa la lista RDAP ``events`` por ``eventAction`` -> ``eventDate``.""" - out: dict = {} - for event in raw.get("events", []) or []: - action = event.get("eventAction") - date = event.get("eventDate") - if action and date: - out[action] = date - return out +def _first_match(raw: str, *labels: str) -> str | None: + """Devuelve el valor de la primera linea cuyo label coincide (case-insensitive). - -def _extract_registrar(raw: dict) -> str | None: - """Busca la entidad con rol ``registrar`` y devuelve su nombre vCard.""" - for entity in raw.get("entities", []) or []: - roles = entity.get("roles", []) or [] - if "registrar" not in roles: - continue - vcard = entity.get("vcardArray") - if isinstance(vcard, list) and len(vcard) == 2: - for field in vcard[1]: - if isinstance(field, list) and field and field[0] == "fn": - return field[3] - return entity.get("handle") + Para cada label busca lineas del tipo ``Label: valor`` ignorando mayusculas + y espacios alrededor de los dos puntos. Devuelve el primer valor no vacio + encontrado, o None si ningun label aparece. + """ + for label in labels: + pattern = re.compile( + r"^\s*" + re.escape(label) + r"\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, + ) + for m in pattern.finditer(raw): + value = m.group(1).strip() + if value: + return value return None -def _extract_nameservers(raw: dict) -> list: - """Extrae los ldhName de los nameservers RDAP, ordenados.""" - servers = [] - for ns in raw.get("nameservers", []) or []: - name = ns.get("ldhName") - if name: - servers.append(name.lower()) - return sorted(set(servers)) +def _all_matches(raw: str, *labels: str) -> list[str]: + """Devuelve todos los valores (deduplicados, en orden) para los labels dados.""" + out: list[str] = [] + seen: set[str] = set() + for label in labels: + pattern = re.compile( + r"^\s*" + re.escape(label) + r"\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, + ) + for m in pattern.finditer(raw): + value = m.group(1).strip() + if value and value.lower() not in seen: + seen.add(value.lower()) + out.append(value) + return out -def whois_lookup(dominio: str, timeout_s: float = 15.0) -> dict: - """Consulta RDAP de un dominio y normaliza la informacion de registro. +def parse_whois_raw(raw: str, target: str) -> dict: + """Parsea best-effort el texto crudo de `whois` en campos normalizados. - Usa ``http_get_json`` del registry contra ``https://rdap.org/domain/`` - (rdap.org redirige al servidor RDAP autoritativo del TLD). Normaliza - registrar, fechas (creacion / expiracion / ultimo cambio), nameservers, - estados y entidades, e incluye la respuesta cruda en ``raw``. + Funcion auxiliar (pura) usada por whois_lookup y por el smoke test. Tolera + la ausencia de cualquier campo (deja None / lista vacia) porque el formato + WHOIS no esta estandarizado y varia por TLD y registrar. Args: - dominio: Dominio a consultar (ej. ``"organic-machine.com"``). - timeout_s: Segundos maximo de espera de la peticion HTTP (default 15). + raw: stdout completo del comando `whois`. + target: dominio o IP consultado (se incluye en el dict de salida). Returns: - Dict normalizado con claves: ``found`` (bool), ``registrar``, - ``creation_date``, ``expiration_date``, ``last_changed``, - ``nameservers`` (lista), ``status`` (lista), ``entities`` (lista de - roles/handles) y ``raw`` (respuesta RDAP completa). Si el dominio no - existe (HTTP 404) devuelve ``{"found": False}``. - - Raises: - RuntimeError: Si el dominio esta vacio o la peticion falla por una - razon distinta de 404. + Dict con status "ok", el raw completo y los campos parseados. """ - if not dominio or not dominio.strip(): - raise RuntimeError("whois_lookup: dominio vacio") + return { + "status": "ok", + "target": target, + "raw": raw, + "registrar": _first_match(raw, "Registrar", "registrar"), + "registrant_country": _first_match(raw, "Registrant Country", "Country"), + "creation_date": _first_match( + raw, "Creation Date", "created", "Created On", "Registered on" + ), + "expiry_date": _first_match( + raw, + "Registry Expiry Date", + "Expiry Date", + "Expiration Date", + "Registrar Registration Expiration Date", + "Expiry", + "expires", + ), + "updated_date": _first_match( + raw, "Updated Date", "Last Modified", "last-modified", "changed" + ), + "name_servers": [ + ns.lower() + for ns in _all_matches(raw, "Name Server", "nserver", "Nameservers") + ], + } - url = f"https://rdap.org/domain/{dominio.strip()}" + +def whois_lookup(target: str, timeout_s: int = 30) -> dict: + """Ejecuta `whois ` y parsea best-effort los campos de registro. + + Funcion IMPURA: lanza el CLI `whois` como subproceso. Captura el stdout + completo (siempre presente en ``raw``) y extrae campos comunes de forma + tolerante. Devuelve un dict; nunca lanza: los errores se reportan como + ``{"status": "error", "error": "..."}``. + + Args: + target: Dominio (ej. ``"google.com"``) o direccion IP a consultar. + timeout_s: Segundos maximo de espera del subproceso (default 30). + + Returns: + Dict de exito:: + + { + "status": "ok", + "target": , + "raw": , + "registrar": str | None, + "registrant_country": str | None, + "creation_date": str | None, + "expiry_date": str | None, + "updated_date": str | None, + "name_servers": [str, ...], + } + + Para IPs varios campos de dominio quedan None. En fallo:: + + {"status": "error", "error": "", "target": } + """ + if not target or not target.strip(): + return {"status": "error", "error": "whois_lookup: target vacio", "target": target} + + target = target.strip() try: - raw = http_get_json(url, timeout=timeout_s) - except RuntimeError as e: - # http_get_json envuelve los HTTPError como "HTTP ". - if "HTTP 404" in str(e): - return {"found": False} - raise - - if not isinstance(raw, dict): - raise RuntimeError( - f"whois_lookup: respuesta RDAP inesperada (tipo {type(raw).__name__})" + proc = subprocess.run( + ["whois", target], + capture_output=True, + text=True, + timeout=timeout_s, ) - - events = _events_by_action(raw) - entities = [ - { - "handle": ent.get("handle"), - "roles": ent.get("roles", []) or [], + except FileNotFoundError: + return { + "status": "error", + "error": "whois_lookup: binario 'whois' no encontrado (instala con `apt install whois`)", + "target": target, } - for ent in raw.get("entities", []) or [] - ] + except subprocess.TimeoutExpired: + return { + "status": "error", + "error": f"whois_lookup: timeout tras {timeout_s}s consultando '{target}'", + "target": target, + } + except OSError as e: # pragma: no cover - errores de SO raros + return {"status": "error", "error": f"whois_lookup: {e}", "target": target} - return { - "found": True, - "registrar": _extract_registrar(raw), - "creation_date": events.get("registration"), - "expiration_date": events.get("expiration"), - "last_changed": events.get("last changed"), - "nameservers": _extract_nameservers(raw), - "status": raw.get("status", []) or [], - "entities": entities, - "raw": raw, - } + raw = proc.stdout or "" + # whois suele devolver stdout incluso con rc != 0; solo es error duro si no + # hubo NADA de salida util. + if not raw.strip(): + err = (proc.stderr or "").strip() or f"whois devolvio salida vacia (rc={proc.returncode})" + return {"status": "error", "error": f"whois_lookup: {err}", "target": target} + + return parse_whois_raw(raw, target) + + +if __name__ == "__main__": + # Smoke test: el assert core NO depende de red — parsea un sample whois + # hardcoded. Tras eso intenta una consulta real, tolerando fallo de red. + SAMPLE = """\ +Domain Name: GOOGLE.COM +Registrar: MarkMonitor Inc. +Registrant Country: US +Creation Date: 1997-09-15T04:00:00Z +Registry Expiry Date: 2028-09-14T04:00:00Z +Updated Date: 2019-09-09T15:39:04Z +Name Server: NS1.GOOGLE.COM +Name Server: NS2.GOOGLE.COM +""" + parsed = parse_whois_raw(SAMPLE, "google.com") + assert parsed["status"] == "ok", parsed + assert parsed["registrar"] == "MarkMonitor Inc.", parsed["registrar"] + assert parsed["registrant_country"] == "US", parsed["registrant_country"] + assert parsed["creation_date"] == "1997-09-15T04:00:00Z", parsed["creation_date"] + assert parsed["expiry_date"] == "2028-09-14T04:00:00Z", parsed["expiry_date"] + assert parsed["updated_date"] == "2019-09-09T15:39:04Z", parsed["updated_date"] + assert parsed["name_servers"] == ["ns1.google.com", "ns2.google.com"], parsed["name_servers"] + assert parsed["raw"] == SAMPLE + print("smoke parse OK") + + # Consulta real, best-effort (no rompe el smoke si no hay red). + live = whois_lookup("google.com") + print("live status:", live["status"]) + if live["status"] == "ok": + print(" registrar:", live.get("registrar")) + print(" name_servers:", live.get("name_servers")) + else: + print(" (red no disponible o whois fallo, tolerado):", live.get("error")) diff --git a/python/functions/cybersecurity/whois_lookup_test.py b/python/functions/cybersecurity/whois_lookup_test.py index d19af6ea..b2004440 100644 --- a/python/functions/cybersecurity/whois_lookup_test.py +++ b/python/functions/cybersecurity/whois_lookup_test.py @@ -1,109 +1,59 @@ -"""Tests para whois_lookup.""" +"""Tests para whois_lookup (CLI `whois`, estilo dict sin excepciones).""" import os import sys sys.path.insert(0, os.path.dirname(__file__)) -import whois_lookup as wl -from whois_lookup import whois_lookup +from whois_lookup import parse_whois_raw, whois_lookup + +SAMPLE = """\ +Domain Name: GOOGLE.COM +Registrar: MarkMonitor Inc. +Registrant Country: US +Creation Date: 1997-09-15T04:00:00Z +Registry Expiry Date: 2028-09-14T04:00:00Z +Updated Date: 2019-09-09T15:39:04Z +Name Server: NS1.GOOGLE.COM +Name Server: NS2.GOOGLE.COM +""" -def _rdap_sample() -> dict: - return { - "ldhName": "organic-machine.com", - "status": ["client transfer prohibited"], - "events": [ - {"eventAction": "registration", "eventDate": "2020-01-15T10:00:00Z"}, - {"eventAction": "expiration", "eventDate": "2027-01-15T10:00:00Z"}, - {"eventAction": "last changed", "eventDate": "2026-01-10T08:30:00Z"}, - ], - "nameservers": [ - {"ldhName": "ns1.example.net"}, - {"ldhName": "NS2.EXAMPLE.NET"}, - ], - "entities": [ - { - "handle": "REG-123", - "roles": ["registrar"], - "vcardArray": [ - "vcard", - [ - ["version", {}, "text", "4.0"], - ["fn", {}, "text", "Example Registrar Inc."], - ], - ], - }, - {"handle": "REGISTRANT-9", "roles": ["registrant"]}, - ], - } +def test_parsea_campos_comunes(): + """Extrae registrar, pais, fechas y nameservers de un sample whois.""" + parsed = parse_whois_raw(SAMPLE, "google.com") + + assert parsed["status"] == "ok" + assert parsed["target"] == "google.com" + assert parsed["registrar"] == "MarkMonitor Inc." + assert parsed["registrant_country"] == "US" + assert parsed["creation_date"] == "1997-09-15T04:00:00Z" + assert parsed["expiry_date"] == "2028-09-14T04:00:00Z" + assert parsed["updated_date"] == "2019-09-09T15:39:04Z" + assert parsed["name_servers"] == ["ns1.google.com", "ns2.google.com"] + assert parsed["raw"] == SAMPLE -def test_normaliza_respuesta_rdap(monkeypatch): - """Extrae registrar, fechas, nameservers, status y entities.""" - monkeypatch.setattr(wl, "http_get_json", lambda url, timeout=15.0: _rdap_sample()) +def test_campos_ausentes_quedan_none(): + """Un raw minimo deja los campos opcionales en None / lista vacia.""" + parsed = parse_whois_raw("Domain Name: x.com\n", "x.com") - result = whois_lookup("organic-machine.com") - - assert result["found"] is True - assert result["registrar"] == "Example Registrar Inc." - assert result["creation_date"] == "2020-01-15T10:00:00Z" - assert result["expiration_date"] == "2027-01-15T10:00:00Z" - assert result["last_changed"] == "2026-01-10T08:30:00Z" - assert result["nameservers"] == ["ns1.example.net", "ns2.example.net"] - assert result["status"] == ["client transfer prohibited"] - assert {"handle": "REGISTRANT-9", "roles": ["registrant"]} in result["entities"] - assert result["raw"]["ldhName"] == "organic-machine.com" + assert parsed["status"] == "ok" + assert parsed["registrar"] is None + assert parsed["creation_date"] is None + assert parsed["expiry_date"] is None + assert parsed["name_servers"] == [] -def test_dominio_no_encontrado_404(monkeypatch): - """Un HTTP 404 de http_get_json devuelve {'found': False}.""" - - def fake(url, timeout=15.0): - raise RuntimeError("http_get_json: HTTP 404 at 'rdap.org' — not found") - - monkeypatch.setattr(wl, "http_get_json", fake) - - result = whois_lookup("nope-no-existe-xyz.invalid") - - assert result == {"found": False} +def test_raw_siempre_presente(): + """El campo raw refleja siempre el texto de entrada tal cual.""" + raw = "Random: noise\n" + parsed = parse_whois_raw(raw, "noise.test") + assert parsed["raw"] == raw -def test_otro_error_http_se_propaga(monkeypatch): - """Un error HTTP distinto de 404 se propaga como RuntimeError.""" - - def fake(url, timeout=15.0): - raise RuntimeError("http_get_json: HTTP 500 at 'rdap.org' — boom") - - monkeypatch.setattr(wl, "http_get_json", fake) - - try: - whois_lookup("organic-machine.com") - assert False, "deberia haberse propagado el error 500" - except RuntimeError as e: - assert "HTTP 500" in str(e) - - -def test_sin_registrar_ni_fechas(monkeypatch): - """RDAP minimo: campos opcionales quedan None / listas vacias.""" - monkeypatch.setattr( - wl, "http_get_json", lambda url, timeout=15.0: {"ldhName": "x.com"} - ) - - result = whois_lookup("x.com") - - assert result["found"] is True - assert result["registrar"] is None - assert result["creation_date"] is None - assert result["nameservers"] == [] - assert result["status"] == [] - assert result["entities"] == [] - - -def test_dominio_vacio_lanza_error(): - """Dominio vacio lanza RuntimeError.""" - try: - whois_lookup("") - assert False, "deberia haber lanzado RuntimeError" - except RuntimeError: - pass +def test_target_vacio_devuelve_error(): + """Un target vacio devuelve status error sin lanzar.""" + result = whois_lookup("") + assert result["status"] == "error" + assert "vacio" in result["error"] diff --git a/python/functions/pipelines/fingerprint_web_stack.md b/python/functions/pipelines/fingerprint_web_stack.md new file mode 100644 index 00000000..d0563388 --- /dev/null +++ b/python/functions/pipelines/fingerprint_web_stack.md @@ -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:}. 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. diff --git a/python/functions/pipelines/fingerprint_web_stack.py b/python/functions/pipelines/fingerprint_web_stack.py new file mode 100644 index 00000000..f0f4a40a --- /dev/null +++ b/python/functions/pipelines/fingerprint_web_stack.py @@ -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": , + "final_url": , + "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": {: [, ...], ...}, + "count": int, + "saved": | None, + "raw": "# fingerprint_web_stack ...\nTECHNOLOGY ...", + } + + error (el fetch HTTP fallo: host no resuelve, conexion rechazada, + timeout):: + + {"status": "error", "stage": "fetch", "url": , "fetch": } + """ + # 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: [--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)) diff --git a/python/functions/pipelines/fingerprint_web_stack_test.py b/python/functions/pipelines/fingerprint_web_stack_test.py new file mode 100644 index 00000000..1a72a2ac --- /dev/null +++ b/python/functions/pipelines/fingerprint_web_stack_test.py @@ -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 `` +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"\n" + b"\n\n" + b"\n" + b"\n" + b"Mi Blog WordPress\n" + b"\n" + b"\n\n" + b"\n" + b"

Hola mundo desde wp-content.

\n" + b"\n\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 diff --git a/python/functions/pipelines/recon_osint.md b/python/functions/pipelines/recon_osint.md new file mode 100644 index 00000000..9c91c2c9 --- /dev/null +++ b/python/functions/pipelines/recon_osint.md @@ -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:} 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). diff --git a/python/functions/pipelines/recon_osint.py b/python/functions/pipelines/recon_osint.py new file mode 100644 index 00000000..8884aee1 --- /dev/null +++ b/python/functions/pipelines/recon_osint.py @@ -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": } 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: [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)) diff --git a/python/functions/pipelines/recon_osint_test.py b/python/functions/pipelines/recon_osint_test.py new file mode 100644 index 00000000..5d6a06d6 --- /dev/null +++ b/python/functions/pipelines/recon_osint_test.py @@ -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 diff --git a/python/functions/pipelines/scan_port_services.md b/python/functions/pipelines/scan_port_services.md new file mode 100644 index 00000000..3e4c54ba --- /dev/null +++ b/python/functions/pipelines/scan_port_services.md @@ -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:}. 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. diff --git a/python/functions/pipelines/scan_port_services.py b/python/functions/pipelines/scan_port_services.py new file mode 100644 index 00000000..d25e9c52 --- /dev/null +++ b/python/functions/pipelines/scan_port_services.py @@ -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": , + "ip": , + "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": | None, + "raw": "# scan_port_services ...\nPORT EXPECTED ...", + } + + error (el escaneo de puertos fallo: host no resuelve, spec invalida):: + + {"status": "error", "stage": "scan", "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: [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)) diff --git a/python/functions/pipelines/scan_port_services_test.py b/python/functions/pipelines/scan_port_services_test.py new file mode 100644 index 00000000..08fd010b --- /dev/null +++ b/python/functions/pipelines/scan_port_services_test.py @@ -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