feat(recon): grupo de reconocimiento de red + servicios + fingerprint web

Añade el capability group `recon` (dominio cybersecurity + pipelines, Python),
con la política de archivado OSINT y página madre docs/capabilities/recon.md.

Lookups y sondeo (wrappers de CLI):
- whois_lookup, rdap_lookup, dns_records, ping_host, traceroute_host, nmap_scan
- save_scan_to_osint (sink común) + recon_osint (pipeline one-shot scan+archivado)

Escaneo de puertos/servicios nativo (stdlib, sin nmap ni sudo):
- scan_tcp_ports: connect-scan TCP concurrente (open/closed/filtered)
- grab_service_banner: banner grab + identificación de servicio/versión real
- identify_port_service: puro, puerto -> servicio IANA esperado (~120 puertos)
- scan_port_services: pipeline one-shot (scan -> identify + banner por puerto abierto)

Fingerprint de tecnología web (estilo Wappalyzer), patrón pura/impura:
- fetch_http_fingerprint: GET stdlib, recoge headers/html/cookies (solo nombres)
- detect_web_tech: puro, matchea ~50 firmas regex -> tecnologías por categoría
- fingerprint_web_stack: pipeline one-shot url -> tecnologías

Todas devuelven dict {status} sin lanzar. Tests: 43 verdes, sin red externa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:12:07 +02:00
parent d89da1292d
commit 935008ec3f
49 changed files with 6659 additions and 302 deletions
@@ -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",
@@ -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 <script>."
- name: cookies
desc: "lista de NOMBRES de cookies (no valores). Default None -> []. Ej: ['PHPSESSID', 'wordpress_logged_in']."
- name: final_url
desc: "URL final tras redirects, para firmas basadas en host/path. Opcional, default ''."
output: "dict con technologies (lista de {name, category, version, confidence, evidence} ordenada deterministicamente por categoria y nombre), by_category (dict categoria -> lista de nombres) y count (entero). confidence es 'high' para match directo de header/meta/cookie/url y 'medium' para HTML generico, script src o tecnologia implicada. version es best-effort (a menudo ''). Para entrada vacia devuelve technologies [], by_category {}, count 0. Nunca lanza."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests: ["test_nginx_por_header_con_version", "test_wordpress_por_html_y_meta_implica_php", "test_php_por_cookie", "test_cloudflare_por_header", "test_entrada_vacia", "test_entrada_vacia_explicita_headers_y_html", "test_determinismo", "test_count_y_by_category_consistentes", "test_headers_claves_mayusculas_se_normalizan", "test_jquery_por_script_src_es_medium"]
test_file_path: "python/functions/cybersecurity/detect_web_tech_test.py"
file_path: "python/functions/cybersecurity/detect_web_tech.py"
---
## Ejemplo
```python
from cybersecurity import detect_web_tech
# Senales fake de un sitio WordPress sobre nginx.
headers = {
"server": "nginx/1.24.0",
"x-powered-by": "PHP/8.2",
}
html = (
'<html><head>'
'<meta name="generator" content="WordPress 6.4">'
'</head><body><link href="/wp-content/themes/x/style.css"></body></html>'
)
cookies = ["PHPSESSID", "wordpress_logged_in_abc"]
result = detect_web_tech(headers, html=html, cookies=cookies)
# result["count"] == 3
# result["by_category"] == {
# "cms": ["WordPress"],
# "programming-language": ["PHP"],
# "web-server": ["nginx"],
# }
# nginx -> version "1.24.0", confidence "high", evidence "header server: nginx/1.24.0"
# WordPress -> version "6.4", confidence "high", evidence "meta generator: WordPress 6.4"
# PHP -> version "8.2", confidence "high", evidence "header x-powered-by: PHP/8.2"
```
Flujo real componiendo con la capa impura hermana (recoleccion -> matching):
```python
from cybersecurity import fetch_http_fingerprint, detect_web_tech
# Capa impura: recoge las senales con un GET real (red).
fp = fetch_http_fingerprint("https://example.com")
# fp = {"headers": {...lowercase...}, "html": "...", "cookies": [...], "final_url": "..."}
# Capa pura: identifica el stack sobre las senales recogidas (sin red).
tech = detect_web_tech(
fp["headers"],
html=fp.get("html", ""),
cookies=fp.get("cookies"),
final_url=fp.get("final_url", ""),
)
for t in tech["technologies"]:
print(t["category"], t["name"], t["version"], t["confidence"])
```
## Cuando usarla
Cuando ya tienes los headers + html (+ cookies/URL) de una URL — recogidos por
`fetch_http_fingerprint_py_cybersecurity` — y quieres saber el stack tecnologico
del sitio: servidor, lenguaje, CMS, frameworks JS, librerias, analytics, CDN,
ecommerce y WAF. Usala como pieza de matching pura y testeable. Para el flujo
one-shot `url -> tecnologias` (recoger + detectar en una llamada) usa el pipeline
`fingerprint_web_stack`.
## Gotchas
- La tabla `SIGNATURES` es un **subconjunto curado** de lo que cubre Wappalyzer
(~50 tecnologias), no es exhaustiva. Para ampliarla, anade entradas nuevas a la
constante `SIGNATURES` del modulo siguiendo el formato documentado en el codigo
(matchers `headers`/`html`/`meta_generator`/`cookies`/`script_src`/`url`,
opcionales `version_group` e `implies`).
- La deteccion por HTML generico puede dar **falsos positivos**: un sitio que
mencione "wordpress" o "woocommerce" en su texto/blog puede matchear sin usarlo
realmente. Por eso esos matches tienen `confidence: "medium"` mientras que
header/meta/cookie directos son `"high"`.
- Las **SPAs cargan los frameworks por JS en runtime**. Un fetch estatico (sin
ejecutar JavaScript) ve el HTML inicial, que en muchas SPAs esta casi vacio
(`<div id="root"></div>`). React/Vue/Angular pueden NO detectarse si el HTML
servido no contiene aun sus marcadores. Para esos casos hace falta renderizar
con un navegador headless, fuera del alcance de esta funcion pura.
- Las **versiones son best-effort**: solo se extraen cuando el regex que disparo
tiene un group de version y este matcheo. A menudo quedan en `""`.
- Es PURA y determinista: misma entrada -> misma salida. Para entrada vacia
(`headers={}, html=""`) devuelve `technologies: [], count: 0` y NUNCA lanza ni
reporta status/error.
@@ -0,0 +1,431 @@
"""Detector de tecnologia web estilo Wappalyzer (pieza pura).
Dado el resultado crudo de un fetch HTTP (cabeceras, HTML, cookies, URL final),
identifica las tecnologias web que usa un sitio matcheando contra una tabla de
firmas embebida (regex): servidor, lenguaje, CMS, frameworks JS, librerias,
analytics, CDN, e-commerce, WAF, etc.
Esta funcion es PURA: no toca la red ni hace I/O. Recibe las senales ya
recogidas por la capa impura hermana (`fetch_http_fingerprint_py_cybersecurity`)
y se limita a aplicar regex deterministas sobre ellas. Separar el matching de la
recoleccion permite testear las firmas sin red y reutilizar la tabla.
La tabla `SIGNATURES` es un subconjunto curado de lo que cubre Wappalyzer (no es
exhaustiva). Para ampliarla, anadir entradas nuevas a `SIGNATURES` siguiendo el
formato documentado mas abajo.
"""
import re
__all__ = ["detect_web_tech", "SIGNATURES"]
# ---------------------------------------------------------------------------
# Tabla de firmas embebida.
#
# Cada firma es un dict con un `name`, una `category` y uno o varios matchers
# (todos opcionales; OR entre tipos: basta que UNO matchee para detectar):
#
# "headers": {"<header-lowercase>": r"<regex>"} -> regex por header
# "html": r"<regex>" -> regex sobre el HTML
# "meta_generator": r"<regex>" -> regex sobre <meta name=generator content=...>
# "cookies": r"<regex>" -> regex sobre nombres de cookies
# "script_src": r"<regex>" -> regex sobre src de <script>
# "url": r"<regex>" -> regex sobre la URL final
#
# Campos opcionales:
# "version_group": <int> -> de que group del matcher que disparo sacar la version
# "implies": ["Tech", ...] -> tecnologias implicadas (confidence menor)
#
# Confidence: "high" si matchea header/meta/cookie/url directo; "medium" si por
# HTML/script_src generico o por `implies`.
# ---------------------------------------------------------------------------
SIGNATURES = [
# ---- web-server -------------------------------------------------------
{"name": "nginx", "category": "web-server",
"headers": {"server": r"nginx(?:/([\d.]+))?"}, "version_group": 1},
{"name": "Apache", "category": "web-server",
"headers": {"server": r"Apache(?:/([\d.]+))?"}, "version_group": 1},
{"name": "IIS", "category": "web-server",
"headers": {"server": r"(?:Microsoft-)?IIS(?:/([\d.]+))?"}, "version_group": 1},
{"name": "LiteSpeed", "category": "web-server",
"headers": {"server": r"LiteSpeed"}},
{"name": "Caddy", "category": "web-server",
"headers": {"server": r"Caddy"}},
{"name": "Gunicorn", "category": "web-server",
"headers": {"server": r"gunicorn(?:/([\d.]+))?"}, "version_group": 1,
"implies": ["Python"]},
{"name": "Werkzeug", "category": "web-server",
"headers": {"server": r"Werkzeug(?:/([\d.]+))?"}, "version_group": 1,
"implies": ["Python"]},
{"name": "Phusion Passenger", "category": "web-server",
"headers": {"server": r"Phusion Passenger(?:[ /]([\d.]+))?"}, "version_group": 1,
"implies": ["Ruby"]},
{"name": "Tomcat", "category": "web-server",
"headers": {"server": r"(?:Apache-Coyote|Tomcat)(?:/([\d.]+))?"}, "version_group": 1,
"implies": ["Java"]},
# ---- programming-language --------------------------------------------
{"name": "PHP", "category": "programming-language",
"headers": {"x-powered-by": r"PHP(?:/([\d.]+))?"},
"cookies": r"^PHPSESSID$", "version_group": 1},
{"name": "ASP.NET", "category": "programming-language",
"headers": {"x-aspnet-version": r"([\d.]+)", "x-powered-by": r"ASP\.NET"},
"cookies": r"^ASP\.NET_SessionId$", "version_group": 1},
{"name": "Java", "category": "programming-language",
"cookies": r"^JSESSIONID$"},
{"name": "Python", "category": "programming-language",
"headers": {"x-powered-by": r"Python(?:/([\d.]+))?"}, "version_group": 1},
{"name": "Ruby", "category": "programming-language",
"headers": {"x-powered-by": r"Phusion Passenger|mod_rails"}},
# ---- web-framework ----------------------------------------------------
{"name": "Express", "category": "web-framework",
"headers": {"x-powered-by": r"Express"}, "implies": ["Node.js"]},
{"name": "Django", "category": "web-framework",
"cookies": r"^csrftoken$|^django", "implies": ["Python"]},
{"name": "Flask", "category": "web-framework",
"cookies": r"^session$", "headers": {"server": r"Werkzeug"},
"implies": ["Python"]},
{"name": "Laravel", "category": "web-framework",
"cookies": r"^laravel_session$|^XSRF-TOKEN$", "implies": ["PHP"]},
{"name": "Ruby on Rails", "category": "web-framework",
"headers": {"x-runtime": r"([\d.]+)"},
"cookies": r"_session_id$|^_rails", "version_group": 1, "implies": ["Ruby"]},
{"name": "ASP.NET MVC", "category": "web-framework",
"headers": {"x-aspnetmvc-version": r"([\d.]+)"}, "version_group": 1,
"implies": ["ASP.NET"]},
{"name": "Next.js", "category": "web-framework",
"html": r"id=[\"']__NEXT_DATA__[\"']|/_next/static/",
"headers": {"x-powered-by": r"Next\.js"}, "implies": ["React", "Node.js"]},
{"name": "Nuxt.js", "category": "web-framework",
"html": r"window\.__NUXT__|/_nuxt/", "implies": ["Vue.js"]},
# ---- cms --------------------------------------------------------------
{"name": "WordPress", "category": "cms",
"html": r"wp-content|wp-includes",
"meta_generator": r"WordPress(?:[ /]?([\d.]+))?",
"cookies": r"^wordpress_|^wp-settings",
"script_src": r"/wp-includes/js/", "version_group": 1, "implies": ["PHP"]},
{"name": "Drupal", "category": "cms",
"headers": {"x-generator": r"Drupal(?:[ /]?([\d.]+))?"},
"html": r"Drupal\.settings|sites/(?:all|default)/(?:themes|modules)",
"meta_generator": r"Drupal(?:[ /]?([\d.]+))?", "version_group": 1,
"implies": ["PHP"]},
{"name": "Joomla", "category": "cms",
"meta_generator": r"Joomla!?(?:[ /]?([\d.]+))?",
"html": r"/media/jui/|com_content", "version_group": 1, "implies": ["PHP"]},
{"name": "Ghost", "category": "cms",
"meta_generator": r"Ghost(?:[ /]?([\d.]+))?",
"html": r"content=[\"']Ghost", "version_group": 1, "implies": ["Node.js"]},
{"name": "Wix", "category": "cms",
"headers": {"x-wix-request-id": r".+"},
"html": r"static\.wixstatic\.com|X-Wix"},
{"name": "Squarespace", "category": "cms",
"html": r"static\.squarespace\.com|squarespace\.com",
"meta_generator": r"Squarespace"},
# ---- js-framework -----------------------------------------------------
{"name": "React", "category": "js-framework",
"html": r"data-reactroot|data-reactid|__REACT_DEVTOOLS"},
{"name": "Vue.js", "category": "js-framework",
"html": r"data-v-[0-9a-f]{6,}|__VUE__|id=[\"']app[\"'][^>]*data-v-"},
{"name": "Angular", "category": "js-framework",
"html": r"ng-version=[\"']([\d.]+)[\"']|ng-app|<app-root", "version_group": 1},
{"name": "Svelte", "category": "js-framework",
"html": r"svelte-[0-9a-z]{6,}|__svelte"},
{"name": "Ember.js", "category": "js-framework",
"html": r"ember-application|id=[\"']ember"},
{"name": "Backbone.js", "category": "js-framework",
"script_src": r"backbone(?:\.min)?\.js"},
# ---- js-library -------------------------------------------------------
{"name": "jQuery", "category": "js-library",
"script_src": r"jquery[.-]?([\d.]+)?(?:\.min)?\.js", "version_group": 1},
{"name": "Bootstrap", "category": "js-library",
"script_src": r"bootstrap[.-]?([\d.]+)?(?:\.min)?\.js",
"html": r"class=[\"'][^\"']*\b(?:container-fluid|navbar-toggler|col-md-)\b",
"version_group": 1},
{"name": "Lodash", "category": "js-library",
"script_src": r"lodash(?:\.min)?\.js"},
{"name": "Underscore.js", "category": "js-library",
"script_src": r"underscore(?:\.min)?\.js"},
{"name": "Modernizr", "category": "js-library",
"script_src": r"modernizr(?:[.-]?[\d.]+)?(?:\.min)?\.js",
"html": r"class=[\"'][^\"']*\bjs\b[^\"']*\bno-js\b"},
{"name": "Moment.js", "category": "js-library",
"script_src": r"moment(?:\.min)?\.js"},
# ---- analytics / tag --------------------------------------------------
{"name": "Google Analytics", "category": "analytics",
"html": r"google-analytics\.com/(?:ga|analytics)\.js|gtag\(|googletagmanager\.com/gtag/js",
"script_src": r"google-analytics\.com|googletagmanager\.com/gtag"},
{"name": "Google Tag Manager", "category": "analytics",
"html": r"googletagmanager\.com/gtm\.js|GTM-[A-Z0-9]+",
"script_src": r"googletagmanager\.com/gtm\.js"},
{"name": "Facebook Pixel", "category": "analytics",
"html": r"connect\.facebook\.net/[^/]+/fbevents\.js|fbq\("},
{"name": "Hotjar", "category": "analytics",
"html": r"static\.hotjar\.com|hotjar\.com/c/hotjar|hjid:",
"script_src": r"static\.hotjar\.com"},
{"name": "Matomo", "category": "analytics",
"html": r"matomo\.js|piwik\.js|_paq\.push",
"script_src": r"matomo\.js|piwik\.js"},
# ---- cdn --------------------------------------------------------------
{"name": "Cloudflare", "category": "cdn",
"headers": {"cf-ray": r".+", "server": r"cloudflare"}},
{"name": "Fastly", "category": "cdn",
"headers": {"x-served-by": r"cache-.+|.+fastly.*", "x-fastly-request-id": r".+",
"via": r".*Fastly.*"}},
{"name": "Akamai", "category": "cdn",
"headers": {"x-akamai-transformed": r".+", "server": r"AkamaiGHost"}},
{"name": "Amazon CloudFront", "category": "cdn",
"headers": {"x-amz-cf-id": r".+", "via": r".*CloudFront.*",
"x-cache": r".*cloudfront.*"}},
{"name": "jsDelivr", "category": "cdn",
"script_src": r"cdn\.jsdelivr\.net"},
{"name": "unpkg", "category": "cdn",
"script_src": r"unpkg\.com"},
# ---- ecommerce --------------------------------------------------------
{"name": "Shopify", "category": "ecommerce",
"headers": {"x-shopify-stage": r".+", "x-sorting-hat-shopid": r".+"},
"html": r"cdn\.shopify\.com|Shopify\.theme|shopify\.com/s/",
"script_src": r"cdn\.shopify\.com"},
{"name": "WooCommerce", "category": "ecommerce",
"html": r"woocommerce|wc-(?:cart|checkout)|class=[\"'][^\"']*woocommerce",
"cookies": r"^woocommerce_", "implies": ["WordPress", "PHP"]},
{"name": "Magento", "category": "ecommerce",
"html": r"Mage\.Cookies|/skin/frontend/|Magento_",
"cookies": r"^frontend$|^X-Magento", "implies": ["PHP"]},
{"name": "PrestaShop", "category": "ecommerce",
"html": r"prestashop|/modules/.*prestashop",
"meta_generator": r"PrestaShop", "cookies": r"^PrestaShop-",
"implies": ["PHP"]},
# ---- security / waf ---------------------------------------------------
{"name": "Sucuri", "category": "security",
"headers": {"x-sucuri-id": r".+", "x-sucuri-cache": r".+",
"server": r"Sucuri/Cloudproxy"}},
{"name": "Imperva Incapsula", "category": "security",
"headers": {"x-iinfo": r".+", "x-cdn": r"Incapsula"},
"cookies": r"^(?:incap_ses|visid_incap)"},
{"name": "Cloudflare WAF", "category": "security",
"cookies": r"^__cf(?:duid|_bm)$|^cf_clearance$"},
# ---- runtime ----------------------------------------------------------
{"name": "Node.js", "category": "programming-language",
"headers": {"x-powered-by": r"Express|Node"}},
]
def _compile_signatures(signatures):
"""Compila los regex de cada firma a nivel modulo (una sola vez).
Devuelve una lista paralela a `signatures` donde cada matcher textual ha
sido reemplazado por un patron compilado con re.IGNORECASE. Es una
transformacion pura sobre la constante; no muta `signatures`.
"""
compiled = []
for sig in signatures:
c = {"name": sig["name"], "category": sig["category"]}
if "version_group" in sig:
c["version_group"] = sig["version_group"]
if "implies" in sig:
c["implies"] = list(sig["implies"])
if "headers" in sig:
c["headers"] = {
k.lower(): re.compile(v, re.IGNORECASE)
for k, v in sig["headers"].items()
}
for key in ("html", "meta_generator", "cookies", "script_src", "url"):
if key in sig:
c[key] = re.compile(sig[key], re.IGNORECASE)
compiled.append(c)
return compiled
# Regex compilados a nivel modulo: constante, inmutable en la practica.
_COMPILED = _compile_signatures(SIGNATURES)
# Regex auxiliares para extraer senales del HTML (compilados una vez).
_META_GENERATOR_RE = re.compile(
r"<meta[^>]+name=[\"']generator[\"'][^>]+content=[\"']([^\"']*)[\"']",
re.IGNORECASE,
)
_SCRIPT_SRC_RE = re.compile(
r"<script[^>]+src=[\"']([^\"']+)[\"']", re.IGNORECASE
)
def _version_from(match, version_group):
"""Extrae la version de un match dado el group, best-effort.
Devuelve "" si no hay group, el group esta vacio o el indice no existe.
"""
if not match or not version_group:
return ""
try:
v = match.group(version_group)
except (IndexError, re.error):
return ""
return v or ""
def detect_web_tech(headers, html="", cookies=None, final_url=""):
"""Detecta tecnologias web a partir de senales de un fetch HTTP.
Pieza PURA de un detector estilo Wappalyzer: matchea una tabla de firmas
embebida (regex) contra las cabeceras, el HTML, los nombres de cookies y la
URL final ya recogidos por la capa impura hermana
(`fetch_http_fingerprint_py_cybersecurity`). No toca la red ni hace I/O.
Args:
headers: dict de cabeceras de respuesta con claves LOWERCASE (tal como
las devuelve fetch_http_fingerprint en su campo `headers`). Los
valores son strings. Si las claves no vinieran en minusculas se
normalizan internamente.
html: HTML de la pagina como string. Default "" (permite detectar solo
por cabeceras y cookies).
cookies: lista de NOMBRES de cookies (no valores). Default None -> [].
final_url: URL final tras redirects (para firmas basadas en host/path).
Opcional, default "".
Returns:
dict con:
- "technologies": lista de dicts
{name, category, version, confidence, evidence}, ordenada por
(categoria, nombre) de forma determinista.
- "by_category": dict categoria -> lista ordenada de nombres.
- "count": numero de tecnologias detectadas.
Para entrada vacia (headers={}, html="") devuelve
{"technologies": [], "by_category": {}, "count": 0}. Nunca lanza.
"""
headers = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
cookies = list(cookies or [])
html = html or ""
final_url = final_url or ""
# Pre-extrae senales derivadas del HTML una sola vez.
meta_generators = _META_GENERATOR_RE.findall(html)
script_srcs = _SCRIPT_SRC_RE.findall(html)
# name -> registro acumulado de la deteccion.
detected = {}
def _record(name, category, version, confidence, evidence):
prev = detected.get(name)
if prev is None:
detected[name] = {
"name": name,
"category": category,
"version": version or "",
"confidence": confidence,
"evidence": evidence,
}
return
# Dedup: combina. Mejor version no vacia y mejor confidence ganan.
if not prev["version"] and version:
prev["version"] = version
if confidence == "high" and prev["confidence"] != "high":
prev["confidence"] = "high"
prev["evidence"] = evidence
for sig in _COMPILED:
name = sig["name"]
category = sig["category"]
vgroup = sig.get("version_group", 0)
# ---- headers (high) ----
matched = False
if "headers" in sig:
for hkey, hre in sig["headers"].items():
val = headers.get(hkey)
if val is None:
continue
m = hre.search(val)
if m:
version = _version_from(m, vgroup)
_record(name, category, version, "high",
f"header {hkey}: {val}")
matched = True
break
# ---- meta generator (high) ----
if not matched and "meta_generator" in sig:
for gen in meta_generators:
m = sig["meta_generator"].search(gen)
if m:
version = _version_from(m, vgroup)
_record(name, category, version, "high",
f"meta generator: {gen}")
matched = True
break
# ---- cookies (high) ----
if not matched and "cookies" in sig:
for ck in cookies:
m = sig["cookies"].search(ck)
if m:
_record(name, category, "", "high", f"cookie: {ck}")
matched = True
break
# ---- url (high) ----
if not matched and "url" in sig and final_url:
m = sig["url"].search(final_url)
if m:
version = _version_from(m, vgroup)
_record(name, category, version, "high",
f"url: {final_url}")
matched = True
# ---- script src (medium) ----
if not matched and "script_src" in sig:
for src in script_srcs:
m = sig["script_src"].search(src)
if m:
version = _version_from(m, vgroup)
_record(name, category, version, "medium",
f"script src: {src}")
matched = True
break
# ---- html generico (medium) ----
if not matched and "html" in sig and html:
m = sig["html"].search(html)
if m:
version = _version_from(m, vgroup)
_record(name, category, version, "medium",
"html pattern")
matched = True
# ---- implies: anade tecnologias implicadas (confidence medium) ----
# Itera sobre una copia: si una tech directa implica otra, la implicada se
# anade solo si no estaba ya detectada directamente.
catalog = {sig["name"]: sig["category"] for sig in _COMPILED}
for sig in _COMPILED:
if sig["name"] not in detected or "implies" not in sig:
continue
for imp_name in sig["implies"]:
if imp_name in detected:
continue
imp_cat = catalog.get(imp_name, "unknown")
_record(imp_name, imp_cat, "", "medium",
f"implied by {sig['name']}")
technologies = sorted(
detected.values(), key=lambda t: (t["category"], t["name"])
)
by_category = {}
for tech in technologies:
by_category.setdefault(tech["category"], []).append(tech["name"])
return {
"technologies": technologies,
"by_category": by_category,
"count": len(technologies),
}
@@ -0,0 +1,108 @@
"""Tests para detect_web_tech (deteccion de tecnologia web estilo Wappalyzer)."""
from detect_web_tech import detect_web_tech
def _names(result):
return {t["name"] for t in result["technologies"]}
def _by_name(result, name):
for t in result["technologies"]:
if t["name"] == name:
return t
return None
def test_nginx_por_header_con_version():
result = detect_web_tech({"server": "nginx/1.24.0"})
assert "nginx" in _names(result)
nginx = _by_name(result, "nginx")
assert nginx["version"] == "1.24.0"
assert nginx["category"] == "web-server"
assert nginx["confidence"] == "high"
def test_wordpress_por_html_y_meta_implica_php():
html = (
'<html><head>'
'<meta name="generator" content="WordPress 6.4">'
'</head><body>'
'<link href="/wp-content/themes/x/style.css">'
'</body></html>'
)
result = detect_web_tech({}, html=html)
names = _names(result)
assert "WordPress" in names
assert "PHP" in names # implied
wp = _by_name(result, "WordPress")
assert wp["version"] == "6.4"
assert wp["confidence"] == "high"
php = _by_name(result, "PHP")
assert php["confidence"] == "medium"
assert "implied by WordPress" in php["evidence"]
def test_php_por_cookie():
result = detect_web_tech({}, cookies=["PHPSESSID"])
php = _by_name(result, "PHP")
assert php is not None
assert php["category"] == "programming-language"
assert php["confidence"] == "high"
assert "PHPSESSID" in php["evidence"]
def test_cloudflare_por_header():
result = detect_web_tech({"server": "cloudflare", "cf-ray": "8a1b2c3d4e5f-MAD"})
cf = _by_name(result, "Cloudflare")
assert cf is not None
assert cf["category"] == "cdn"
assert cf["confidence"] == "high"
def test_entrada_vacia():
result = detect_web_tech({})
assert result["technologies"] == []
assert result["by_category"] == {}
assert result["count"] == 0
def test_entrada_vacia_explicita_headers_y_html():
result = detect_web_tech({}, html="", cookies=None, final_url="")
assert result["count"] == 0
assert result["technologies"] == []
def test_determinismo():
headers = {"server": "nginx/1.24.0", "x-powered-by": "PHP/8.2"}
html = '<meta name="generator" content="WordPress 6.4">wp-content'
a = detect_web_tech(headers, html=html, cookies=["PHPSESSID"])
b = detect_web_tech(headers, html=html, cookies=["PHPSESSID"])
assert a == b
def test_count_y_by_category_consistentes():
headers = {"server": "nginx/1.24.0"}
html = '<meta name="generator" content="WordPress 6.4">wp-content'
result = detect_web_tech(headers, html=html)
assert result["count"] == len(result["technologies"])
total_in_categories = sum(len(v) for v in result["by_category"].values())
assert total_in_categories == result["count"]
assert "nginx" in result["by_category"]["web-server"]
assert "WordPress" in result["by_category"]["cms"]
def test_headers_claves_mayusculas_se_normalizan():
result = detect_web_tech({"Server": "Apache/2.4.57"})
apache = _by_name(result, "Apache")
assert apache is not None
assert apache["version"] == "2.4.57"
def test_jquery_por_script_src_es_medium():
html = '<script src="/static/jquery-3.6.0.min.js"></script>'
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"
+42 -23
View File
@@ -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 <tipo> <dominio>` 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 <domain> <TYPE>` 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`.
+79 -36
View File
@@ -1,63 +1,106 @@
"""Recoleccion OSINT pasiva de registros DNS via el binario `dig`.
Funcion IMPURA: ejecuta `dig +short <tipo> <dominio>` 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 <domain> <TYPE>`
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 <tipo> <dominio>`` 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 <domain> <TYPE>``
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": <domain>,
"records": {"A": [...], "MX": [...], ...},
"raw": "=== A ===\\n...\\n=== MX ===\\n...",
}
En fallo (binario ausente, dominio vacio)::
{"status": "error", "error": <str>, "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)
@@ -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"]
@@ -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://`.
@@ -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"<title[^>]*>(.*?)</title>", 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 <title> 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"])
@@ -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</title>. NO toca red externa.
"""
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from fetch_http_fingerprint import fetch_http_fingerprint
_HTML = b"<!doctype html><html><head><title>Hola</title></head><body>ok</body></html>"
class _FakeHandler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 (firma de BaseHTTPRequestHandler)
self.send_response(200)
self.send_header("Server", "TestServer/1.0")
self.send_header("X-Powered-By", "PHP/8.1")
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Set-Cookie", "PHPSESSID=secret_value_no_capturar; Path=/")
self.end_headers()
self.wfile.write(_HTML)
def log_message(self, *args): # silencia el logging del server en los tests
pass
def _start_server():
server = HTTPServer(("127.0.0.1", 0), _FakeHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
def test_status_ok_y_status_code_200():
server, thread = _start_server()
try:
port = server.server_address[1]
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
assert res["status"] == "ok", res
assert res["status_code"] == 200, res["status_code"]
finally:
server.shutdown()
thread.join(timeout=2)
def test_headers_normalizados_lowercase():
server, thread = _start_server()
try:
port = server.server_address[1]
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
assert res["headers"]["server"] == "TestServer/1.0", res["headers"]
assert res["server"] == "TestServer/1.0", res["server"]
assert res["headers"]["x-powered-by"] == "PHP/8.1", res["headers"]
finally:
server.shutdown()
thread.join(timeout=2)
def test_cookies_solo_nombres_no_valores():
server, thread = _start_server()
try:
port = server.server_address[1]
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
assert "PHPSESSID" in res["cookies"], res["cookies"]
# El valor sensible NUNCA debe aparecer en la salida.
assert "secret_value_no_capturar" not in res["raw"], "valor de cookie filtrado en raw"
assert all("=" not in c for c in res["cookies"]), res["cookies"]
finally:
server.shutdown()
thread.join(timeout=2)
def test_title_extraido():
server, thread = _start_server()
try:
port = server.server_address[1]
res = fetch_http_fingerprint(f"http://127.0.0.1:{port}/")
assert res["title"] == "Hola", res["title"]
assert res["html_len"] == len(_HTML), res["html_len"]
finally:
server.shutdown()
thread.join(timeout=2)
def test_url_vacia_devuelve_error():
res = fetch_http_fingerprint("")
assert res["status"] == "error", res
assert "url vacia" in res["error"], res["error"]
def test_host_inexistente_devuelve_error_sin_lanzar():
# Puerto cerrado en loopback: conexion rechazada, debe devolver error, no lanzar.
res = fetch_http_fingerprint("http://127.0.0.1:1/")
assert res["status"] == "error", res
assert res["url"] == "http://127.0.0.1:1/", res
if __name__ == "__main__":
test_status_ok_y_status_code_200()
test_headers_normalizados_lowercase()
test_cookies_solo_nombres_no_valores()
test_title_extraido()
test_url_vacia_devuelve_error()
test_host_inexistente_devuelve_error_sin_lanzar()
print("all tests passed")
@@ -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"`.
@@ -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)
@@ -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
@@ -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`.
@@ -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)))
@@ -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"
+131
View File
@@ -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 <ports>' 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-<profile>-<target>-<timestamp>.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).
+295
View File
@@ -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 <ports>" 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-<profile>-<target>-<timestamp>.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 <profile_flags> [-p ports] [extra_args] -oX <xml> <target>
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}")
@@ -0,0 +1,170 @@
"""Tests para nmap_scan (wrapper nmap, estilo dict sin excepciones).
SIN red: nunca ejecuta nmap real. subprocess.run se monkeypatchea para que el
guard y el parseo de XML se prueben de forma determinista y offline.
"""
import os
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(__file__))
from nmap_scan import _parse_xml, _target_is_private, nmap_scan
# XML fixture minimo de nmap: un host up con el puerto 22/tcp open (ssh).
NMAP_XML = """\
<?xml version="1.0" encoding="UTF-8"?>
<nmaprun scanner="nmap">
<host>
<status state="up" reason="syn-ack"/>
<address addr="192.168.1.10" addrtype="ipv4"/>
<ports>
<port protocol="tcp" portid="22">
<state state="open" reason="syn-ack"/>
<service name="ssh" product="OpenSSH" version="8.9"/>
</port>
<port protocol="tcp" portid="80">
<state state="closed" reason="reset"/>
<service name="http"/>
</port>
</ports>
</host>
</nmaprun>
"""
def _make_fake_run(write_xml: bool = True, returncode: int = 0):
"""Devuelve un fake de subprocess.run que escribe el XML fixture en la ruta
que sigue a '-oX' en el comando y devuelve un CompletedProcess."""
def fake_run(cmd, *args, **kwargs):
if write_xml:
idx = cmd.index("-oX")
xml_path = cmd[idx + 1]
with open(xml_path, "w", encoding="utf-8") as fh:
fh.write(NMAP_XML)
return subprocess.CompletedProcess(
args=cmd, returncode=returncode, stdout="raw nmap output", stderr=""
)
return fake_run
def _fail_if_called(*args, **kwargs):
"""subprocess.run que falla el test si se invoca (el guard NO debe ejecutar nmap)."""
raise AssertionError("subprocess.run no debe llamarse cuando el guard rechaza el target")
# --- 1. _parse_xml: golden ---------------------------------------------------
def test_parse_xml_extrae_puertos_abiertos_y_hosts_up():
"""Escribe el XML fixture a un tmp y comprueba el parseo."""
fd, xml_path = tempfile.mkstemp(suffix=".xml")
os.close(fd)
try:
with open(xml_path, "w", encoding="utf-8") as fh:
fh.write(NMAP_XML)
open_ports, hosts_up, host_status = _parse_xml(xml_path)
assert hosts_up == ["192.168.1.10"]
assert host_status == "up"
# Solo el puerto 22 esta open; el 80 esta closed y se descarta.
assert len(open_ports) == 1
p = open_ports[0]
assert p["port"] == 22
assert p["proto"] == "tcp"
assert p["state"] == "open"
assert p["service"] == "ssh"
assert p["product"] == "OpenSSH"
assert p["version"] == "8.9"
finally:
os.remove(xml_path)
# --- 2. Guard error path: publico sin confirm no ejecuta nmap ----------------
def test_guard_publico_sin_confirm_rechaza_y_no_ejecuta(monkeypatch):
"""8.8.8.8 (publico) sin confirm -> error + needs_confirm, sin tocar subprocess."""
monkeypatch.setattr(subprocess, "run", _fail_if_called)
result = nmap_scan("8.8.8.8")
assert result["status"] == "error"
assert result["needs_confirm"] is True
assert "8.8.8.8" in result["error"]
# --- 3. Guard privado OK: procede y parsea -----------------------------------
def test_guard_privado_procede_y_parsea(monkeypatch):
"""192.168.1.10 (privado) sin confirm -> procede; XML parseado."""
monkeypatch.setattr(subprocess, "run", _make_fake_run())
result = nmap_scan("192.168.1.10")
assert result["status"] == "ok"
assert result["hosts_up"] == ["192.168.1.10"]
assert len(result["open_ports"]) == 1
assert result["open_ports"][0]["port"] == 22
assert result["raw"] == "raw nmap output"
# --- 4. Guard confirm=True sobre publico procede -----------------------------
def test_guard_confirm_true_sobre_publico_procede(monkeypatch):
"""8.8.8.8 (publico) con confirm=True -> procede."""
monkeypatch.setattr(subprocess, "run", _make_fake_run())
result = nmap_scan("8.8.8.8", confirm=True)
assert result["status"] == "ok"
assert len(result["open_ports"]) == 1
# --- 5. Guard allowlist: target autorizado procede ---------------------------
def test_guard_allowlist_procede(monkeypatch):
"""scanme.nmap.org en allowlist -> procede sin confirm."""
monkeypatch.setattr(subprocess, "run", _make_fake_run())
result = nmap_scan("scanme.nmap.org", allowlist=["scanme.nmap.org"])
assert result["status"] == "ok"
# --- 6. Errores de validacion ------------------------------------------------
def test_perfil_invalido_devuelve_error(monkeypatch):
"""Un perfil no listado -> status error, sin ejecutar nmap."""
monkeypatch.setattr(subprocess, "run", _fail_if_called)
result = nmap_scan("192.168.1.10", profile="noexiste")
assert result["status"] == "error"
assert "invalido" in result["error"]
def test_target_vacio_devuelve_error(monkeypatch):
"""Target vacio -> status error, sin ejecutar nmap."""
monkeypatch.setattr(subprocess, "run", _fail_if_called)
result = nmap_scan("")
assert result["status"] == "error"
assert "vacio" in result["error"]
# --- 7. _target_is_private: clasificacion ------------------------------------
def test_target_is_private_clasifica():
"""Privados/local -> True; publico -> False; hostname publico -> None."""
assert _target_is_private("10.0.0.1") is True
assert _target_is_private("127.0.0.1") is True
assert _target_is_private("192.168.0.0/24") is True
assert _target_is_private("8.8.8.8") is False
assert _target_is_private("localhost") is True
assert _target_is_private("foo.local") is True
assert _target_is_private("example.com") is None
@@ -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 <count> -w <timeout_s> <host>` (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`.
+130
View File
@@ -0,0 +1,130 @@
"""Sondeo de disponibilidad ICMP de un host via el binario `ping` (Linux).
Funcion IMPURA: ejecuta `ping -c <count> -w <timeout_s> <host>` 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": <host>,
"packets_sent": <int>,
"packets_recv": <int>,
"loss_pct": <float>,
"rtt_avg_ms": <float|None>,
"rtt_min_ms": <float|None>,
"rtt_max_ms": <float|None>,
"raw": <stdout>,
}
En fallo (binario ausente, host vacio, timeout duro)::
{"status": "error", "error": <str>, "host": <host>, "raw": <stdout|"">}
"""
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)
@@ -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"]
@@ -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 <target>`, 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.
@@ -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 <target>` 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": <target>,
"raw": <JSON crudo como string>,
"data": <dict parseado | None>,
"handle": <data["handle"] | None>,
"ldhName": <data["ldhName"] | None>,
"warning": <str>, # solo si el JSON no parseo
}
En fallo de ejecucion::
{"status": "error", "error": "<mensaje>", "target": <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"))
@@ -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"
@@ -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/<slug>/recon/<scan_type>-<ts>.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.
@@ -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/<slug>/recon/<scan_type>-<ts>.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']}")
@@ -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"])
@@ -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`.
@@ -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": <host>,
"ip": <ip resuelta>,
"ports_scanned": <int>,
"open": [<int>, ...], # ordenada
"closed_count": <int>,
"filtered_count": <int>,
"results": [{"port": int, "state": str}, ...], # ordenado por puerto
"raw": <bloque PORT/STATE legible para evidencia OSINT>,
}
error (host no resuelve, spec invalida)::
{"status": "error", "error": <str>, "host": <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)
@@ -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"]
@@ -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 <max_hops> -w 2 <host>` (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.
@@ -0,0 +1,143 @@
"""Trazado de la ruta de red hacia un host via el binario `traceroute` (Linux).
Funcion IMPURA: ejecuta `traceroute -m <max_hops> -w 2 <host>` 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": <host>,
"hops": [
{"hop": 1, "hosts": [{"name": str, "ip": str, "rtt_ms": [float, ...]}]},
{"hop": 2, "hosts": []}, # "* * *" sin respuesta
...
],
"raw": <stdout>,
}
En fallo (binario ausente, host vacio, timeout duro)::
{"status": "error", "error": <str>, "host": <host>, "raw": <stdout|"">}
"""
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)
@@ -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"]
+52 -36
View File
@@ -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/<dominio> 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 <target>` 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`.
+168 -91
View File
@@ -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/<dominio>``
(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 <target>` 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": <target>,
"raw": <stdout completo del whois>,
"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": "<mensaje>", "target": <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 <code>".
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"))
@@ -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"]
@@ -0,0 +1,121 @@
---
name: fingerprint_web_stack
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def fingerprint_web_stack(url: str, timeout_s: float = 15.0, verify_tls: bool = True, max_html_bytes: int = 500_000, save: bool = True) -> dict"
description: "One-shot que detecta la tecnologia web (stack tecnologico estilo Wappalyzer) de una URL: hace el fetch HTTP de las senales (fetch_http_fingerprint) y matchea las firmas (detect_web_tech), devolviendo las tecnologias detectadas — servidor, lenguaje, CMS, framework web, frameworks JS, librerias, analytics, CDN, e-commerce, WAF — con categoria, version y confidence. Reemplaza el patron fetch_http_fingerprint -> detect_web_tech por una sola llamada. El equivalente registry de Wappalyzer / whatweb / un fingerprint de stack de una url. Opcionalmente archiva la evidencia (tabla TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE) en OSINT. Util para reconocimiento web, auditoria de superficie y averiguar que CMS framework servidor usa un sitio."
tags: [recon, web-recon, pipelines, cybersecurity, fingerprint, wappalyzer, web-tech, sink]
uses_functions:
- fetch_http_fingerprint_py_cybersecurity
- detect_web_tech_py_cybersecurity
- save_scan_to_osint_py_cybersecurity
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: url
desc: "URL del sitio objetivo (ej. https://example.com). Sin esquema se asume https:// con fallback a http://, igual que fetch_http_fingerprint."
- name: timeout_s
desc: "Timeout de la peticion HTTP en segundos. Default 15.0. Se pasa tal cual a fetch_http_fingerprint."
- name: verify_tls
desc: "Si False, no verifica el certificado TLS (inseguro, solo para hosts propios con cert self-signed). Default True. Se pasa a fetch_http_fingerprint."
- name: max_html_bytes
desc: "Corta el HTML leido a este tamano para no descargar megas. Default 500_000 (500 KB). Se pasa a fetch_http_fingerprint."
- name: save
desc: "Si True (default) archiva la evidencia en OSINT via save_scan_to_osint con scan_type='web_tech' (target = host de la URL); si False solo ejecuta el fetch + matching y no toca el vault ni el service osint_db. Politica recon: todo scan se archiva. Si el sink falla, el resultado degrada sin romper (saved.status='error')."
output: "dict con status ('ok'|'error'), url, final_url (tras redirects), status_code (int), server (cabecera Server o ''), title (titulo de la pagina o ''), technologies (lista de dicts con name, category, version, confidence, evidence — tal cual de detect_web_tech), by_category (dict categoria -> lista de nombres), count (int), saved (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False) y raw (tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE con cabecera de url/status/server/title). Si el fetch HTTP falla (host no resuelve, conexion rechazada, timeout) -> {status:error, stage:fetch, url:..., fetch:<dict>}. Nunca lanza."
tested: true
tests: ["test_golden_fingerprint_servidor_local_wordpress_nginx", "test_save_false_no_archiva_osint", "test_fetch_fallido_propaga_error_sin_red"]
test_file_path: "python/functions/pipelines/fingerprint_web_stack_test.py"
file_path: "python/functions/pipelines/fingerprint_web_stack.py"
---
## Ejemplo
```python
from pipelines.fingerprint_web_stack import fingerprint_web_stack
# Fingerprint del stack tecnologico de un sitio, en 1 paso (sin archivar).
r = fingerprint_web_stack("https://example.com", save=False)
print(r["status"]) # "ok"
print(r["server"]) # "nginx/1.24.0"
print(r["count"]) # 5
for t in r["technologies"]:
print(t["name"], t["category"], t["version"], t["confidence"])
# WordPress cms 6.4 high
# nginx web-server 1.24.0 high
# PHP programming-language medium
print(r["by_category"]) # {"cms": ["WordPress"], "web-server": ["nginx"], ...}
```
```python
from pipelines.fingerprint_web_stack import fingerprint_web_stack
# Con archivado en OSINT (default): deja una nota en el vault + POST al osint_db.
r = fingerprint_web_stack("https://midominio.example")
print(r["saved"]["note_path"]) # dominios/midominio.example/recon/web_tech-....md
```
```bash
# Por CLI: detecta el stack de un sitio.
./fn run fingerprint_web_stack https://example.com
# Flags: --no-save (no archiva OSINT), --no-verify-tls (cert self-signed, inseguro).
./fn run fingerprint_web_stack https://example.com --no-save
```
## Cuando usarla
Cuando quieras en UN solo paso saber que tecnologia usa un sitio web (servidor,
CMS, frameworks JS, lenguaje, analytics, CDN, WAF) — el equivalente registry de
Wappalyzer. Reemplaza el patron `fetch_http_fingerprint` -> `detect_web_tech`
(un fetch + un matching). Tipico para: reconocimiento web inicial de un
objetivo, averiguar el CMS/framework de un sitio antes de un pentest
autorizado, auditar la superficie tecnologica de tus propios dominios, o
enriquecer una investigacion OSINT con el stack de un host.
## Gotchas
- **Fetch estatico: NO ejecuta JavaScript.** Solo ve el HTML inicial que devuelve
el servidor. Las SPAs que montan el framework (React/Vue/Angular/Svelte) en
runtime suelen servir un HTML casi vacio, asi que esos frameworks pueden NO
detectarse. Para sitios JS-pesados, un fingerprint con navegador real (CDP)
veria mas; este pipeline es la version sin navegador.
- **La tabla de firmas es un subconjunto de Wappalyzer**, no exhaustiva. Un
tecnologia no listada en `detect_web_tech` no aparecera aunque este presente.
Para ampliar cobertura, anade entradas a `SIGNATURES` en `detect_web_tech`.
- **`verify_tls=False` es inseguro**: desactiva la verificacion del certificado
(MITM posible). Usalo solo contra hosts propios con cert self-signed.
- **Un WAF/anti-bot puede devolver un challenge** (Cloudflare, Imperva...) en vez
del sitio real: en ese caso las tecnologias detectadas seran las del WAF y el
HTML del challenge, no las del sitio de fondo.
- **save=True escribe en el vault OSINT** (`~/Obsidian/osint`) y hace POST al
service `osint_db` (`http://127.0.0.1:8771`). Si el service esta caido,
`save_scan_to_osint` degrada a solo-nota (`saved.registered=False` con
`register_warning`); el pipeline no falla por eso.
- **Autorizacion legal**: hacer fingerprint de hosts ajenos puede entrar en
reconocimiento no autorizado. Respeta el scope y la autorizacion explicita;
usalo sobre objetivos propios o consentidos.
- **Pipeline impuro**: hace red (fetch HTTP) y, con `save=True`, FS/HTTP (vault +
service). No es determinista entre ejecuciones.
- Si el fetch HTTP falla del todo (`status != "ok"`: host no resuelve, conexion
rechazada, timeout), el pipeline devuelve `{"status":"error","stage":"fetch",...}`
y **no** intenta matchear firmas ni archivar nada. Un 403/404/500 NO es fallo
de fetch: sigue siendo senal de fingerprint y se procesa con su status_code.
## Notas
Pipeline que compone 3 funciones atomicas del dominio `cybersecurity`. No
reimplementa logica de fetch, matching de firmas ni persistencia: solo orquesta
`fetch_http_fingerprint` (recoleccion de senales, impura) + `detect_web_tech`
(matching de firmas, pura) y delega el guardado en `save_scan_to_osint`. El
`raw` de evidencia incluye una cabecera con url/final_url/status_code/server/
title y una tabla TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE; nunca embebe el HTML
entero ni valores de cookie (las cookies de `fetch_http_fingerprint` ya son solo
nombres). El `target` para el archivado OSINT se deriva del host de la URL
(`urllib.parse.urlparse(...).hostname`). Nunca lanza excepciones: todo fallo se
refleja en la clave `status` del dict devuelto.
@@ -0,0 +1,275 @@
"""Pipeline fingerprint_web_stack.
One-shot que materializa el flujo "averiguar la tecnologia web (stack) de una
URL" estilo Wappalyzer: hace el fetch HTTP de las senales (cabeceras, HTML,
cookies, titulo, servidor) y matchea las firmas para devolver las tecnologias
detectadas (servidor, lenguaje, CMS, frameworks JS, librerias, analytics, CDN,
e-commerce, WAF). Opcionalmente archiva la evidencia en OSINT.
Convierte el patron de 2 llamadas (fetch_http_fingerprint -> detect_web_tech)
en una sola invocacion. Compone funciones del registry del dominio
cybersecurity; no reescribe ninguna logica de fetch, matching de firmas ni
persistencia.
Funciones del registry compuestas (importadas, no reimplementadas):
fetch_http_fingerprint, detect_web_tech, save_scan_to_osint
"""
from urllib.parse import urlparse
from cybersecurity import (
fetch_http_fingerprint,
detect_web_tech,
save_scan_to_osint,
)
def _build_raw(
url: str,
final_url: str,
status_code: int,
server: str,
title: str,
technologies: list[dict],
) -> str:
"""Construye una tabla legible TECNOLOGIA/CATEGORIA/VERSION/CONFIDENCE para evidencia.
NO incluye el HTML entero ni valores de cookie: solo metadatos de respuesta y
la matriz de tecnologias detectadas.
Args:
url: URL solicitada.
final_url: URL final tras redirects.
status_code: codigo HTTP de la respuesta.
server: cadena del servidor (cabecera Server), puede ser "".
title: titulo de la pagina, puede ser "".
technologies: lista de dicts de tecnologia (ver fingerprint_web_stack).
Returns:
Bloque de texto multi-linea con cabecera y una fila por tecnologia.
"""
header_lines = [
f"# fingerprint_web_stack {url}",
"",
f"url: {url}",
f"final_url: {final_url}",
f"status_code: {status_code}",
f"server: {server or '-'}",
f"title: {title or '-'}",
"",
]
cols = f"{'TECHNOLOGY':<24}{'CATEGORY':<22}{'VERSION':<14}CONFIDENCE"
lines = header_lines + [cols]
for t in technologies:
name = str(t.get("name", ""))
category = str(t.get("category", ""))
version = str(t.get("version") or "")
confidence = str(t.get("confidence", ""))
lines.append(f"{name:<24}{category:<22}{version:<14}{confidence}")
if not technologies:
lines.append("(no technologies detected)")
return "\n".join(lines)
def _target_from_url(url: str, final_url: str) -> str:
"""Deriva el target (host) para el archivado OSINT a partir de la URL.
Prefiere el host de la URL solicitada; si no se puede parsear, cae al host de
la URL final tras redirects; si tampoco, devuelve la cadena cruda.
Args:
url: URL solicitada.
final_url: URL final tras redirects.
Returns:
El host (sin esquema ni path), o la URL cruda si no se pudo extraer.
"""
for candidate in (url, final_url):
if not candidate:
continue
try:
host = urlparse(candidate).hostname
except ValueError:
host = None
if host:
return host
return (url or final_url or "unknown").strip()
def fingerprint_web_stack(
url: str,
timeout_s: float = 15.0,
verify_tls: bool = True,
max_html_bytes: int = 500_000,
save: bool = True,
) -> dict:
"""Detecta la tecnologia web (stack) de una URL en un solo paso (estilo Wappalyzer).
Compone, en una sola invocacion:
1. ``fetch_http_fingerprint(url, ...)`` para recoger las senales crudas de
la respuesta (cabeceras, HTML, cookies, titulo, servidor).
2. ``detect_web_tech(headers, html, cookies, final_url)`` (PURA) para
matchear esas senales contra la tabla de firmas y obtener las
tecnologias detectadas.
3. Si ``save`` es True, archiva una tabla de evidencia en OSINT via
``save_scan_to_osint`` con ``scan_type="web_tech"`` (target = host de la
URL).
Nunca lanza excepciones: cualquier fallo se refleja en la clave ``status``
del dict devuelto.
Args:
url: URL objetivo. Sin esquema se asume https:// (fallback a http://),
tal como hace fetch_http_fingerprint.
timeout_s: timeout de la peticion HTTP en segundos. Default 15.0. Se pasa
tal cual a fetch_http_fingerprint.
verify_tls: si False, no verifica el certificado TLS (inseguro, solo para
hosts propios con cert self-signed). Default True. Se pasa a
fetch_http_fingerprint.
max_html_bytes: corta el HTML leido a este tamano para no descargar megas.
Default 500_000 (500 KB). Se pasa a fetch_http_fingerprint.
save: si True (default) archiva la evidencia en OSINT via
save_scan_to_osint con scan_type="web_tech"; si False solo ejecuta el
fetch + matching y no toca el vault ni el service osint_db. Politica
recon: todo scan se archiva. Si el sink falla, el resultado degrada
sin romper (saved.status="error").
Returns:
dict de estado. Nunca lanza.
ok::
{
"status": "ok",
"url": <url solicitada>,
"final_url": <url tras redirects>,
"status_code": int,
"server": str, # cabecera Server, "" si no hay
"title": str, # titulo de la pagina, "" si no hay
"technologies": [ # tal cual de detect_web_tech
{"name", "category", "version", "confidence", "evidence"},
...
],
"by_category": {<categoria>: [<nombre>, ...], ...},
"count": int,
"saved": <dict de save_scan_to_osint> | None,
"raw": "# fingerprint_web_stack ...\nTECHNOLOGY ...",
}
error (el fetch HTTP fallo: host no resuelve, conexion rechazada,
timeout)::
{"status": "error", "stage": "fetch", "url": <url>, "fetch": <dict>}
"""
# 1. Fetch de senales. Si el fetch falla del todo, propagamos sin continuar.
fp = fetch_http_fingerprint(
url,
timeout_s=timeout_s,
verify_tls=verify_tls,
max_html_bytes=max_html_bytes,
)
if fp.get("status") != "ok":
return {
"status": "error",
"stage": "fetch",
"url": url,
"fetch": fp,
}
final_url = fp.get("final_url", "") or ""
status_code = fp.get("status_code", 0)
server = fp.get("server") or ""
title = fp.get("title") or ""
# 2. Matching de firmas (puro): no toca red, solo aplica regex deterministas.
detection = detect_web_tech(
fp.get("headers") or {},
html=fp.get("html") or "",
cookies=fp.get("cookies") or [],
final_url=final_url,
)
technologies = detection.get("technologies", [])
by_category = detection.get("by_category", {})
count = detection.get("count", len(technologies))
raw = _build_raw(url, final_url, status_code, server, title, technologies)
# 3. Archiva la evidencia en OSINT si procede (degrada sin romper).
saved = None
if save:
target = _target_from_url(url, final_url)
summary = {
"count": count,
"by_category": by_category,
"server": server,
"status_code": status_code,
}
saved = save_scan_to_osint(
target,
"web_tech",
raw,
summary=summary,
tool="fingerprint_web_stack",
)
return {
"status": "ok",
"url": url,
"final_url": final_url,
"status_code": status_code,
"server": server,
"title": title,
"technologies": technologies,
"by_category": by_category,
"count": count,
"saved": saved,
"raw": raw,
}
def _parse_cli(argv: list[str]) -> dict:
"""Parsea los args de CLI: <url> [--no-save] [--no-verify-tls].
Devuelve un dict de kwargs para fingerprint_web_stack.
"""
positional: list[str] = []
save = True
verify_tls = True
for arg in argv:
if arg == "--no-save":
save = False
elif arg == "--no-verify-tls":
verify_tls = False
else:
positional.append(arg)
return {"positional": positional, "save": save, "verify_tls": verify_tls}
if __name__ == "__main__":
import sys
parsed = _parse_cli(sys.argv[1:])
positional = parsed["positional"]
target_url = positional[0] if len(positional) >= 1 else "https://example.com"
try:
result = fingerprint_web_stack(
target_url,
verify_tls=parsed["verify_tls"],
save=parsed["save"],
)
print("status:", result.get("status"))
if result.get("status") == "ok":
print(f"url: {result['url']} -> {result['final_url']} ({result['status_code']})")
print("server:", result["server"] or "-")
print("--- technologies ---")
print(result["raw"])
saved = result.get("saved") or {}
if saved:
print("note_path:", saved.get("note_path"))
print("registered:", saved.get("registered"))
else:
print("error:", result.get("fetch", {}).get("error"))
except Exception as exc: # noqa: BLE001 - smoke nunca debe romper
print("smoke exception (tolerada):", repr(exc))
@@ -0,0 +1,180 @@
"""Tests para el pipeline fingerprint_web_stack — SIN red externa ni service real.
El golden levanta un HTTPServer local efimero en 127.0.0.1 que emite cabeceras
(Server: nginx, X-Powered-By: PHP) + un HTML con `<meta name=generator>`
WordPress y marcadores `wp-content`. El pipeline compone fetch_http_fingerprint
+ detect_web_tech contra ese servidor real, asi se ejercita la composicion
end-to-end sin tocar internet. save=False en todos los tests para no escribir en
el vault OSINT ni hacer POST al service.
Para el error path, save_scan_to_osint se parchea sobre los globals del modulo
del pipeline (importlib + monkeypatch) por si acaso, pero con save=False nunca
debe invocarse.
"""
import http.server
import importlib
import os
import socketserver
import sys
import threading
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
# Globals del modulo del pipeline (donde viven fetch_http_fingerprint,
# detect_web_tech, save_scan_to_osint...).
mod = importlib.import_module("pipelines.fingerprint_web_stack")
fingerprint_web_stack = mod.fingerprint_web_stack
# HTML servido por el server local: marcadores claros de WordPress (meta
# generator + wp-content) para que detect_web_tech lo detecte high/medium.
_WP_HTML = (
b"<!DOCTYPE html>\n"
b"<html>\n<head>\n"
b"<meta charset=\"utf-8\">\n"
b"<meta name=\"generator\" content=\"WordPress 6.4.2\">\n"
b"<title>Mi Blog WordPress</title>\n"
b"<link rel=\"stylesheet\" href=\"/wp-content/themes/twenty/style.css\">\n"
b"</head>\n<body>\n"
b"<script src=\"/wp-includes/js/jquery/jquery.min.js\"></script>\n"
b"<p>Hola mundo desde wp-content.</p>\n"
b"</body>\n</html>\n"
)
class _WPHandler(http.server.BaseHTTPRequestHandler):
"""Handler que finge ser un WordPress detras de nginx + PHP."""
# Silencia el logging del server a stderr durante el test.
def log_message(self, *args, **kwargs): # noqa: D102
pass
def do_GET(self): # noqa: N802 - firma impuesta por BaseHTTPRequestHandler
self.send_response(200)
self.send_header("Server", "nginx/1.24.0")
self.send_header("X-Powered-By", "PHP/8.2.10")
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(_WP_HTML)))
self.end_headers()
self.wfile.write(_WP_HTML)
def _start_wp_server() -> tuple[socketserver.TCPServer, int, threading.Thread]:
"""Levanta un HTTPServer efimero en 127.0.0.1 que sirve el HTML WordPress.
Returns:
(httpd, port, thread). El caller debe llamar httpd.shutdown() al final.
"""
httpd = http.server.HTTPServer(("127.0.0.1", 0), _WPHandler)
port = httpd.server_address[1]
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
return httpd, port, t
# --- 1. Golden: fingerprint contra un servidor WordPress/nginx/PHP local ------
def test_golden_fingerprint_servidor_local_wordpress_nginx():
"""Detecta WordPress (CMS), nginx (servidor) y PHP en el HTML/headers locales."""
httpd, port, thread = _start_wp_server()
try:
result = fingerprint_web_stack(
f"http://127.0.0.1:{port}/",
timeout_s=5.0,
save=False,
)
assert result["status"] == "ok", result
assert result["status_code"] == 200, result
# No se archivo en OSINT (save=False).
assert result["saved"] is None, result
# Hubo al menos una tecnologia detectada.
assert result["count"] > 0, result
names = {t["name"] for t in result["technologies"]}
# WordPress por meta generator; nginx por cabecera Server.
assert "WordPress" in names, names
assert "nginx" in names, names
# by_category coherente con las tecnologias.
by_cat = result["by_category"]
assert "WordPress" in by_cat.get("cms", []), by_cat
assert "nginx" in by_cat.get("web-server", []), by_cat
# server y title vienen del fetch.
assert "nginx" in (result["server"] or ""), result["server"]
assert "WordPress" in (result["title"] or ""), result["title"]
# raw es la tabla legible con cabeceras y columnas.
raw = result["raw"]
assert isinstance(raw, str)
assert "TECHNOLOGY" in raw
assert "WordPress" in raw
assert "nginx" in raw
assert str(port) in raw # la URL solicitada aparece en la cabecera
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2.0)
# --- 2. save=False: corre fetch + matching pero NO archiva en OSINT -----------
def test_save_false_no_archiva_osint():
"""save=False: technologies poblado pero el sink nunca se invoca."""
save_called = {"n": 0}
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {"status": "ok"}
httpd, port, thread = _start_wp_server()
original_save = mod.save_scan_to_osint
mod.save_scan_to_osint = fake_save
try:
result = fingerprint_web_stack(
f"http://127.0.0.1:{port}/",
timeout_s=5.0,
save=False,
)
finally:
mod.save_scan_to_osint = original_save
httpd.shutdown()
httpd.server_close()
thread.join(timeout=2.0)
assert result["status"] == "ok", result
assert result["count"] > 0, result
assert result["saved"] is None, result
# El sink nunca se invoco con save=False.
assert save_called["n"] == 0, save_called
# --- 3. Error path: el fetch HTTP falla -> error sin red externa --------------
def test_fetch_fallido_propaga_error_sin_red():
"""Host que no resuelve: fetch_http_fingerprint da error y el pipeline lo propaga."""
save_called = {"n": 0}
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {"status": "ok"}
# Parcheamos el sink: aunque save=True, con fetch fallido no debe invocarse.
original_save = mod.save_scan_to_osint
mod.save_scan_to_osint = fake_save
try:
result = fingerprint_web_stack(
"http://nohost.invalid.tld.example/",
timeout_s=2.0,
save=True,
)
finally:
mod.save_scan_to_osint = original_save
assert result["status"] == "error", result
assert result["stage"] == "fetch", result
assert result["fetch"]["status"] == "error", result
# No se intento archivar nada.
assert save_called["n"] == 0, save_called
+115
View File
@@ -0,0 +1,115 @@
---
name: recon_osint
kind: pipeline
lang: py
domain: pipelines
version: "1.1.0"
purity: impure
signature: "def recon_osint(target: str, scan_type: str = 'whois', save: bool = True, profile: str = 'quick', record_types: list[str] | None = None, count: int = 4, max_hops: int = 30, timeout_s: int | None = None, confirm: bool = False) -> dict"
description: "One-shot que ejecuta un escaneo de red atomico (whois/rdap/dns/ping/traceroute/nmap) y SIEMPRE lo archiva en OSINT. Materializa la politica 'todo escaneo se guarda en osint': convierte el patron de 2 llamadas (scan atomico + sink) en 1 sola."
tags: [recon, osint, pipeline, cybersecurity, osint-passive, sink]
uses_functions:
- whois_lookup_py_cybersecurity
- rdap_lookup_py_cybersecurity
- dns_records_py_cybersecurity
- ping_host_py_cybersecurity
- traceroute_host_py_cybersecurity
- nmap_scan_py_cybersecurity
- save_scan_to_osint_py_cybersecurity
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: target
desc: "Dominio, host o IP objetivo del escaneo (ej. google.com, scanme.nmap.org, 8.8.8.8)."
- name: scan_type
desc: "Tipo de escaneo a ejecutar. Uno de: whois, rdap, dns, ping, traceroute, nmap. Default 'whois'."
- name: save
desc: "Si True (default) archiva el resultado en OSINT via save_scan_to_osint; si False solo ejecuta el escaneo y no toca el vault."
- name: profile
desc: "Perfil de nmap (solo aplica a scan_type='nmap'): quick, full-tcp, vuln, etc. Default 'quick'."
- name: record_types
desc: "Lista de tipos de registro DNS a consultar (solo scan_type='dns'), ej. ['A','MX','TXT']. None usa los defaults del atomico."
- name: count
desc: "Numero de paquetes ICMP a enviar (solo scan_type='ping'). Default 4."
- name: max_hops
desc: "Numero maximo de saltos a sondear (solo scan_type='traceroute'). Default 30."
- name: timeout_s
desc: "Timeout en segundos. Si se pasa, se propaga al escaneo atomico; None deja que cada atomico use su default. Sube este valor para nmap full-tcp/vuln."
- name: confirm
desc: "Confirmacion explicita para el escaneo activo de nmap (solo aplica a scan_type='nmap'); se propaga a nmap_scan(confirm=...). Default False: nmap rechaza targets publicos/desconocidos no autorizados (status error + needs_confirm). Pasar True solo con autorizacion. Se ignora para whois/rdap/dns/ping/traceroute. CLI: flag --confirm (y --allowlist a,b,c que activa confirm si el target esta autorizado)."
output: "dict con status ('ok'|'error'), target, scan_type, scan (dict crudo del atomico) y osint (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False). scan_type invalido -> {status:error, stage:validate, valid:[...]}. Escaneo fallido -> {status:error, stage:scan, scan:<dict>} sin intentar guardar. Nunca lanza."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/recon_osint.py"
---
## Ejemplo
```python
from pipelines.recon_osint import recon_osint
# WHOIS de un dominio: ejecuta el lookup y lo archiva en OSINT (1 paso).
r = recon_osint("google.com", "whois")
print(r["status"]) # "ok"
print(r["osint"]["note_path"]) # ruta de la nota creada en el vault osint
```
```python
from pipelines.recon_osint import recon_osint
# Escaneo nmap rapido de un host autorizado, archivado en OSINT.
r = recon_osint("scanme.nmap.org", "nmap", profile="quick")
print(r["scan"]["open_ports"]) # puertos abiertos detectados
print(r["osint"]["registered"]) # True si el service osint_db lo registro
```
## Cuando usarla
Para lanzar un escaneo de red y archivarlo en OSINT en un solo paso. Usalo en
vez de llamar el atomico (`whois_lookup`, `nmap_scan`, ...) + `save_scan_to_osint`
por separado. Garantiza que el resultado queda en el vault: es la materializacion
de la politica "todo escaneo que lancemos se guarda en osint".
## Gotchas
- Hereda los gotchas de cada escaneo atomico que compone:
- **nmap**: guard anti-escaneo no autorizado — un target publico/desconocido
se rechaza (status error + needs_confirm) salvo que pases `confirm=True`
(o, por CLI, `--confirm` / `--allowlist`). Los privados/local proceden sin
confirm. Solo escanea objetivos para los que tengas autorizacion legal;
escaneos sin permiso pueden ser ilegales. Requiere `nmap` instalado.
- **ping / traceroute**: el ICMP puede estar filtrado por firewalls; un
`loss_pct` 100% o pocos `hops` no implican que el host este caido, solo que
no responde a ICMP.
- **whois / rdap**: dependen de la disponibilidad y rate-limit del servidor
WHOIS/RDAP del TLD; respuestas truncadas o vacias son posibles.
- **dns**: resuelve contra el resolver del sistema; resultados varian segun el
DNS configurado.
- Requiere el service `osint_db` vivo (`http://127.0.0.1:8771`) para el registro
estructurado; si no responde, `save_scan_to_osint` degrada a guardar solo la
nota en el vault (sin entrada en DuckDB). El pipeline no falla por eso.
- Para nmap largos (`full-tcp`, `vuln`) pasa `profile` + un `timeout_s` alto y
considera lanzarlo en background; el default `timeout_s` del atomico nmap es
amplio (1800s) pero un escaneo completo puede excederlo.
- Si el escaneo atomico falla (`status == "error"`), el pipeline devuelve
`{"status":"error","stage":"scan",...}` y **no** intenta guardar nada en OSINT.
- Pipeline impuro: hace red (escaneos) y FS/HTTP (vault + service). No es
determinista entre ejecuciones.
## Notas
Pipeline que compone 7 funciones atomicas del dominio `cybersecurity`. No
reimplementa logica de escaneo ni de persistencia; solo despacha, normaliza un
`summary` por tipo de escaneo y delega el guardado. Nunca lanza excepciones:
todo fallo se refleja en la clave `status` del dict devuelto.
## Capability growth log
- v1.1.0 (2026-06-14) — param `confirm` propagado a `nmap_scan(confirm=...)`
(solo scan_type='nmap') para el guard anti-escaneo-no-autorizado; CLI gana
flag `--confirm` y `--allowlist a,b,c` (activa confirm si el target esta
autorizado). Sin el flag, confirm=False (compatibilidad intacta).
+228
View File
@@ -0,0 +1,228 @@
"""Pipeline recon_osint.
One-shot que ejecuta UN escaneo de red atomico y SIEMPRE lo archiva en OSINT.
Materializa la politica "todo escaneo que lancemos se guarda en osint":
convierte el patron de dos llamadas (scan atomico + sink a OSINT) en una sola
invocacion. Compone funciones del registry del dominio cybersecurity; no
reescribe ninguna logica de escaneo ni de persistencia.
Funciones del registry compuestas (importadas, no reimplementadas):
whois_lookup, rdap_lookup, dns_records, ping_host, traceroute_host,
nmap_scan, save_scan_to_osint
"""
from cybersecurity import (
whois_lookup,
rdap_lookup,
dns_records,
ping_host,
traceroute_host,
nmap_scan,
save_scan_to_osint,
)
VALID_SCAN_TYPES = ("whois", "rdap", "dns", "ping", "traceroute", "nmap")
def recon_osint(
target: str,
scan_type: str = "whois",
save: bool = True,
profile: str = "quick",
record_types: list[str] | None = None,
count: int = 4,
max_hops: int = 30,
timeout_s: int | None = None,
confirm: bool = False,
) -> dict:
"""Ejecuta un escaneo de red atomico y lo archiva en OSINT en un solo paso.
Despacha al escaneo atomico correspondiente segun ``scan_type``, captura su
resultado y, si el escaneo tuvo exito y ``save`` es True, lo guarda en el
vault OSINT via ``save_scan_to_osint``. Nunca lanza excepciones: cualquier
fallo se refleja en la clave ``status`` del dict devuelto.
Args:
target: dominio, host o IP objetivo del escaneo.
scan_type: tipo de escaneo, uno de whois, rdap, dns, ping, traceroute,
nmap.
save: si True (default) archiva el resultado en OSINT; si False solo
ejecuta el escaneo.
profile: perfil de nmap (solo aplica a scan_type='nmap'). Ej. quick,
full-tcp, vuln.
record_types: tipos de registro DNS a consultar (solo scan_type='dns').
None usa los defaults de dns_records.
count: numero de paquetes ICMP (solo scan_type='ping').
max_hops: numero maximo de saltos (solo scan_type='traceroute').
timeout_s: timeout en segundos. Si se pasa, se propaga al escaneo
atomico; si es None, cada atomico usa su default.
confirm: confirmacion explicita para el escaneo activo de nmap (solo
aplica a scan_type='nmap'). Por defecto False: nmap rechaza targets
publicos/desconocidos no autorizados. Pasar True solo cuando el
escaneo este autorizado. Se ignora para los demas scan_type.
Returns:
dict con:
- status: 'ok' | 'error'.
- target: el objetivo escaneado.
- scan_type: el tipo de escaneo ejecutado.
- scan: dict crudo devuelto por la funcion atomica.
- osint: dict devuelto por save_scan_to_osint, o None si save=False.
En caso de scan_type invalido devuelve
{"status": "error", "stage": "validate", ...} con la lista de tipos
validos. Si el escaneo falla devuelve
{"status": "error", "stage": "scan", "scan": <dict>} sin intentar guardar.
"""
if scan_type not in VALID_SCAN_TYPES:
return {
"status": "error",
"stage": "validate",
"target": target,
"scan_type": scan_type,
"error": f"invalid scan_type '{scan_type}'",
"valid": list(VALID_SCAN_TYPES),
}
# 1. Despacho al escaneo atomico correspondiente.
if scan_type == "whois":
kwargs = {} if timeout_s is None else {"timeout_s": timeout_s}
scan = whois_lookup(target, **kwargs)
elif scan_type == "rdap":
kwargs = {} if timeout_s is None else {"timeout_s": timeout_s}
scan = rdap_lookup(target, **kwargs)
elif scan_type == "dns":
kwargs = {"record_types": record_types}
if timeout_s is not None:
kwargs["timeout_s"] = timeout_s
scan = dns_records(target, **kwargs)
elif scan_type == "ping":
kwargs = {"count": count}
if timeout_s is not None:
kwargs["timeout_s"] = timeout_s
scan = ping_host(target, **kwargs)
elif scan_type == "traceroute":
kwargs = {"max_hops": max_hops}
if timeout_s is not None:
kwargs["timeout_s"] = timeout_s
scan = traceroute_host(target, **kwargs)
else: # nmap
kwargs = {"profile": profile, "confirm": confirm}
if timeout_s is not None:
kwargs["timeout_s"] = timeout_s
scan = nmap_scan(target, **kwargs)
# 2. Si el escaneo fallo, no intentamos guardar.
if scan.get("status") == "error":
return {"status": "error", "stage": "scan", "scan": scan}
# 3. Construye el summary segun el tipo de escaneo.
if scan_type == "whois":
summary = {
"registrar": scan.get("registrar"),
"expiry_date": scan.get("expiry_date"),
}
elif scan_type == "rdap":
summary = {
"handle": scan.get("handle"),
"ldhName": scan.get("ldhName"),
}
elif scan_type == "dns":
summary = {"records": scan.get("records")}
elif scan_type == "ping":
summary = {
"loss_pct": scan.get("loss_pct"),
"rtt_avg_ms": scan.get("rtt_avg_ms"),
}
elif scan_type == "traceroute":
summary = {"hop_count": len(scan.get("hops") or [])}
else: # nmap
summary = {
"open_ports": scan.get("open_ports"),
"hosts_up": scan.get("hosts_up"),
"profile": profile,
}
# 4. Archiva en OSINT si procede.
osint = None
if save:
osint = save_scan_to_osint(
target,
scan_type,
scan.get("raw", ""),
summary=summary,
tool=scan_type,
)
return {
"status": "ok",
"target": target,
"scan_type": scan_type,
"scan": scan,
"osint": osint,
}
def _parse_cli(argv: list[str]) -> dict:
"""Parsea los args de CLI: <target> [scan_type] [--confirm] [--allowlist a,b,c].
Mantiene compatibilidad: sin args usa el smoke por defecto; sin --confirm,
confirm=False. Devuelve un dict de kwargs para recon_osint mas la allowlist.
"""
positional: list[str] = []
confirm = False
allowlist: list[str] | None = None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--confirm":
confirm = True
elif arg == "--allowlist":
if i + 1 < len(argv):
allowlist = [a.strip() for a in argv[i + 1].split(",") if a.strip()]
i += 1
elif arg.startswith("--allowlist="):
raw = arg.split("=", 1)[1]
allowlist = [a.strip() for a in raw.split(",") if a.strip()]
else:
positional.append(arg)
i += 1
return {"positional": positional, "confirm": confirm, "allowlist": allowlist}
if __name__ == "__main__":
import sys
parsed = _parse_cli(sys.argv[1:])
positional = parsed["positional"]
target = positional[0] if len(positional) >= 1 else "example.com"
scan_type = positional[1] if len(positional) >= 2 else "whois"
# --confirm activa confirm directamente. --allowlist activa confirm cuando
# el target esta autorizado en la lista (mismo criterio que nmap_scan).
confirm = parsed["confirm"]
allowlist = parsed["allowlist"]
if not confirm and allowlist:
t = target.strip()
if any(t == a or t.endswith(a) for a in allowlist):
confirm = True
try:
result = recon_osint(
target,
scan_type,
confirm=confirm,
)
print("status:", result.get("status"))
# Para nmap rechazado por el guard, el dict trae stage=scan + scan.needs_confirm.
scan = result.get("scan") or {}
if scan.get("needs_confirm"):
print("needs_confirm:", True)
print("error:", scan.get("error"))
osint = result.get("osint") or {}
print("note_path:", osint.get("note_path"))
print("registered:", osint.get("registered"))
except Exception as exc: # smoke tolerante a red / servicios caidos
print("smoke exception (tolerada):", repr(exc))
@@ -0,0 +1,141 @@
"""Tests para el pipeline recon_osint — SIN red ni service real.
Las funciones de escaneo (whois_lookup, nmap_scan, ...) y el sink
save_scan_to_osint se importan en el namespace del modulo del pipeline con
``from cybersecurity import (...)``. Para aislarlos de la red/disco los
parcheamos sobre los globals del propio modulo via importlib + monkeypatch.
Los tests usan kwargs minimos (target + scan_type + save) a proposito: la firma
del pipeline puede ampliarse en paralelo (p.ej. con un parametro ``confirm``)
sin que estos tests dejen de pasar.
"""
import importlib
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
# Globals del modulo del pipeline (donde viven whois_lookup, save_scan_to_osint...).
mod = importlib.import_module("pipelines.recon_osint")
recon_osint = mod.recon_osint
def test_golden_whois_save_true_invoca_scan_y_sink(monkeypatch):
"""scan_type='whois', save=True: ejecuta whois_lookup y archiva con su raw."""
fake_scan = {
"status": "ok",
"target": "example.com",
"registrar": "Acme Registrar",
"expiry_date": "2028-09-14T04:00:00Z",
"raw": "Domain Name: EXAMPLE.COM\nRegistrar: Acme Registrar\n",
}
calls = {}
def fake_whois(target, **kwargs):
calls["whois_target"] = target
return fake_scan
def fake_save(target, scan_type, raw, **kwargs):
calls["save"] = {
"target": target,
"scan_type": scan_type,
"raw": raw,
"kwargs": kwargs,
}
return {
"status": "ok",
"note_path": "dominios/example.com/recon/whois-20260614-1200.md",
"registered": True,
"scan_id": "scan-1",
}
monkeypatch.setattr(mod, "whois_lookup", fake_whois)
monkeypatch.setattr(mod, "save_scan_to_osint", fake_save)
result = recon_osint("example.com", scan_type="whois", save=True)
assert result["status"] == "ok"
assert result["scan_type"] == "whois"
assert result["target"] == "example.com"
# Devuelve el dict crudo del scan.
assert result["scan"] == fake_scan
# Devuelve los datos de archivado del sink.
assert result["osint"]["registered"] is True
assert result["osint"]["scan_id"] == "scan-1"
# whois_lookup recibio el target.
assert calls["whois_target"] == "example.com"
# save_scan_to_osint fue invocado con el raw del scan.
assert "save" in calls
assert calls["save"]["raw"] == fake_scan["raw"]
assert calls["save"]["target"] == "example.com"
assert calls["save"]["scan_type"] == "whois"
def test_save_false_ejecuta_scan_sin_archivar(monkeypatch):
"""save=False: corre el scan pero NO llama save_scan_to_osint."""
fake_scan = {
"status": "ok",
"target": "example.com",
"registrar": "Acme",
"raw": "Domain Name: EXAMPLE.COM\n",
}
save_called = {"n": 0}
monkeypatch.setattr(mod, "whois_lookup", lambda target, **kw: fake_scan)
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {"status": "ok"}
monkeypatch.setattr(mod, "save_scan_to_osint", fake_save)
result = recon_osint("example.com", scan_type="whois", save=False)
assert result["status"] == "ok"
assert result["scan"] == fake_scan
# Sin archivado: osint es None y el sink nunca se invoco.
assert result["osint"] is None
assert save_called["n"] == 0
def test_scan_type_invalido_error_sin_red(monkeypatch):
"""scan_type desconocido: status error sin invocar ninguna funcion de scan/sink."""
# Centinelas que petan si se invocan: el pipeline no debe tocar nada.
def explode(*args, **kwargs): # pragma: no cover - no debe llamarse
raise AssertionError("no debe ejecutarse scan ni sink con scan_type invalido")
monkeypatch.setattr(mod, "whois_lookup", explode)
monkeypatch.setattr(mod, "nmap_scan", explode)
monkeypatch.setattr(mod, "save_scan_to_osint", explode)
result = recon_osint("example.com", scan_type="bogus", save=True)
assert result["status"] == "error"
assert result["stage"] == "validate"
assert result["scan_type"] == "bogus"
assert "valid" in result and isinstance(result["valid"], list)
def test_scan_fallido_no_intenta_archivar(monkeypatch):
"""Si el escaneo devuelve status error, no se llama al sink."""
save_called = {"n": 0}
monkeypatch.setattr(
mod,
"whois_lookup",
lambda target, **kw: {"status": "error", "error": "timeout"},
)
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {}
monkeypatch.setattr(mod, "save_scan_to_osint", fake_save)
result = recon_osint("example.com", scan_type="whois", save=True)
assert result["status"] == "error"
assert result["stage"] == "scan"
assert save_called["n"] == 0
@@ -0,0 +1,116 @@
---
name: scan_port_services
kind: pipeline
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def scan_port_services(host: str, ports: str | list[int] = 'common', timeout_s: float = 1.0, workers: int = 100, grab_banners: bool = True, banner_timeout_s: float = 3.0, save: bool = True) -> dict"
description: "One-shot que escanea los servicios de los puertos de un host: hace un connect-scan TCP y, por cada puerto abierto, devuelve el servicio esperado por convencion IANA (identify_port_service) y el servicio/version REAL leido del banner en vivo (grab_service_banner). Reemplaza el patron scan_tcp_ports -> identify -> grab repetido (1 scan + 2*K por puerto abierto) por una sola llamada. Opcionalmente archiva la evidencia (tabla PORT/EXPECTED/ACTUAL/BANNER) en OSINT. No requiere nmap. Util para fingerprint de servicios, auditoria de superficie de ataque y reconocimiento de puertos de un host."
tags: [recon, pipelines, cybersecurity, port-scan, service-detection, banner, sink]
uses_functions:
- scan_tcp_ports_py_cybersecurity
- identify_port_service_py_cybersecurity
- grab_service_banner_py_cybersecurity
- save_scan_to_osint_py_cybersecurity
uses_types: []
returns: []
returns_optional: false
error_type: "error_py_core"
imports: []
params:
- name: host
desc: "Hostname o IP objetivo del escaneo (ej. 127.0.0.1, scanme.nmap.org, 10.0.0.5)."
- name: ports
desc: "Especificacion de puertos, se pasa tal cual a scan_tcp_ports. Acepta lista de ints ([22,80,443]), preset 'common' (~30 puertos comunes, default), rango '1-1024' o CSV '22,80,443' (con rangos mezclados '22,80,8000-8010')."
- name: timeout_s
desc: "Timeout por conexion TCP del connect-scan, en segundos. Default 1.0. Bajo en redes lentas puede marcar abiertos como filtered."
- name: workers
desc: "Numero de hilos concurrentes del escaneo de puertos. Default 100. Se acota al numero de puertos a escanear."
- name: grab_banners
desc: "Si True (default) llama grab_service_banner por cada puerto abierto para identificar el servicio/version real; si False solo usa identify_port_service (servicio esperado por convencion) sin tocar el servicio en vivo: mas rapido y mas sigiloso (sin segunda ronda de conexiones)."
- name: banner_timeout_s
desc: "Timeout del grab de banner por puerto, en segundos. Default 3.0. Solo aplica si grab_banners=True."
- name: save
desc: "Si True (default) archiva la evidencia en OSINT via save_scan_to_osint con scan_type='port_services'; si False solo ejecuta el escaneo y no toca el vault ni el service osint_db. Politica recon: todo scan se archiva. Si el sink falla, el resultado degrada sin romper (saved.status='error')."
output: "dict con status ('ok'|'error'), host, ip (resuelta), open_ports (lista de ints), services (lista de dicts con port, expected_service, expected_desc, actual_service, product, version, banner, match), saved (dict de save_scan_to_osint con note_path/registered/scan_id, o None si save=False) y raw (tabla legible PORT/EXPECTED/ACTUAL/BANNER). Si el escaneo de puertos falla (host no resuelve, spec invalida) -> {status:error, stage:scan, scan:<dict>}. Cuando grab_banners=False, actual_service/product/version/banner quedan None/'' y match=None. Nunca lanza."
tested: true
tests: ["test_golden_scan_localhost_con_banner_real", "test_grab_banners_false_solo_servicio_esperado", "test_scan_fallido_propaga_error_sin_red", "test_save_false_no_archiva_osint"]
test_file_path: "python/functions/pipelines/scan_port_services_test.py"
file_path: "python/functions/pipelines/scan_port_services.py"
---
## Ejemplo
```python
from pipelines.scan_port_services import scan_port_services
# Escaneo + fingerprint de servicios de un host, archivado en OSINT (1 paso).
r = scan_port_services("127.0.0.1", ports="common")
print(r["status"]) # "ok"
print(r["open_ports"]) # [22, 5432, 6379]
for s in r["services"]:
print(s["port"], s["expected_service"], "->", s["actual_service"], s["version"])
# 22 ssh -> ssh 9.6p1
print(r["saved"]["note_path"]) # ruta de la nota creada en el vault osint
```
```python
from pipelines.scan_port_services import scan_port_services
# Solo servicio esperado por convencion (sin tocar el servicio en vivo), sin archivar.
r = scan_port_services("10.0.0.5", ports=[22, 80, 443, 3306], grab_banners=False, save=False)
print(r["raw"]) # tabla PORT/EXPECTED/ACTUAL/BANNER (ACTUAL vacio)
```
```bash
# Por CLI: escanea los puertos comunes de un host.
./fn run scan_port_services 127.0.0.1 common
# Flags: --no-banners (solo servicio esperado), --no-save (no archiva OSINT).
./fn run scan_port_services scanme.nmap.org 22,80,443 --no-save
```
## Cuando usarla
Cuando quieras en UN solo paso saber que puertos estan abiertos en un host Y
que servicio/version corre en cada uno, sin nmap. Reemplaza el patron repetido
`scan_tcp_ports` -> `identify_port_service` -> `grab_service_banner` (una ronda
de scan + dos llamadas por cada puerto abierto). Tipico para: fingerprint de
servicios de un objetivo, auditoria de superficie de ataque, validar que un
puerto abierto corre el servicio esperado (campo `match`), o reconocimiento
inicial de un host autorizado.
## Gotchas
- **Ruidoso/detectable**: es un connect-scan (handshake TCP completo) seguido,
si `grab_banners=True`, de una segunda conexion por puerto para leer el
banner. Deja rastro en logs del objetivo. Usa `grab_banners=False` para un
paso menos invasivo (sin segunda ronda).
- **Servicios sobre TLS no dan banner plano**: puertos como 443/993/995/8443
hablan TLS, no emiten un banner texto al conectar, asi que `actual_service`
quedara `"unknown"` ahi (no hay handshake TLS en `grab_service_banner`). El
`expected_service` (https/imaps/...) si lo identifica por convencion.
- **match es heuristico**: `match=True` solo cuando expected y actual son ambos
concretos (no "unknown") y coinciden. Un `match=False` puede significar
"no coinciden" o "no se pudo determinar el real"; mira `actual_service`.
- **save=True escribe en el vault OSINT** (`~/Obsidian/osint`) y hace POST al
service `osint_db` (`http://127.0.0.1:8771`). Si el service esta caido,
`save_scan_to_osint` degrada a solo-nota (`saved.registered=False` con
`register_warning`); el pipeline no falla por eso.
- **Autorizacion legal**: escanear puertos y leer banners de hosts ajenos sin
permiso puede ser ilegal. Solo objetivos propios o con autorizacion explicita.
- **Pipeline impuro**: hace red (scan + banners) y FS/HTTP (vault + service).
No es determinista entre ejecuciones.
- Si el escaneo de puertos falla (`status != "ok"`: host no resuelve, spec
invalida), el pipeline devuelve `{"status":"error","stage":"scan",...}` y
**no** intenta identificar servicios ni archivar nada.
## Notas
Pipeline que compone 4 funciones atomicas del dominio `cybersecurity`. No
reimplementa logica de escaneo, identificacion ni persistencia: solo orquesta
`scan_tcp_ports` (puertos abiertos) + `identify_port_service` (servicio esperado,
puro) + `grab_service_banner` (servicio real, por puerto abierto) y delega el
guardado en `save_scan_to_osint`. El grab de banners es secuencial por KISS (los
puertos abiertos suelen ser pocos y cada grab ya tiene timeout acotado). Nunca
lanza excepciones: todo fallo se refleja en la clave `status` del dict devuelto.
@@ -0,0 +1,254 @@
"""Pipeline scan_port_services.
One-shot que materializa el flujo "escanear los servicios de los diferentes
puertos de un host": escanea puertos TCP y, para cada puerto ABIERTO, obtiene
(a) el servicio que se espera por convencion IANA en ese puerto y (b) el
servicio/version REAL leyendo el banner del servicio en vivo. Opcionalmente
archiva la evidencia en OSINT.
Convierte el patron de N llamadas (1 scan + 2*K por cada puerto abierto:
identify_port_service + grab_service_banner) en una sola invocacion. Compone
funciones del registry del dominio cybersecurity; no reescribe ninguna logica
de escaneo, identificacion ni persistencia.
Funciones del registry compuestas (importadas, no reimplementadas):
scan_tcp_ports, identify_port_service, grab_service_banner,
save_scan_to_osint
"""
from cybersecurity import (
scan_tcp_ports,
identify_port_service,
grab_service_banner,
save_scan_to_osint,
)
def _build_raw(host: str, ip: str, services: list[dict]) -> str:
"""Construye una tabla legible PORT / EXPECTED / ACTUAL / BANNER para evidencia.
Args:
host: host objetivo del escaneo.
ip: IP resuelta del host.
services: lista de dicts de servicio (ver scan_port_services).
Returns:
Bloque de texto multi-linea con cabecera y una fila por puerto abierto.
"""
header = f"# scan_port_services {host} ({ip})"
cols = f"{'PORT':<8}{'EXPECTED':<16}{'ACTUAL':<16}BANNER"
lines = [header, "", cols]
for s in services:
port = str(s.get("port", ""))
expected = str(s.get("expected_service") or "")
actual = str(s.get("actual_service") or "")
banner = (s.get("banner") or "").replace("\r", " ").replace("\n", " ").strip()
if len(banner) > 80:
banner = banner[:77] + "..."
lines.append(f"{port:<8}{expected:<16}{actual:<16}{banner}")
return "\n".join(lines)
def scan_port_services(
host: str,
ports: "str | list[int]" = "common",
timeout_s: float = 1.0,
workers: int = 100,
grab_banners: bool = True,
banner_timeout_s: float = 3.0,
save: bool = True,
) -> dict:
"""Escanea puertos de un host e identifica el servicio (esperado y real) de cada uno.
Compone, en un solo paso:
1. ``scan_tcp_ports(host, ports, ...)`` para hallar los puertos abiertos.
2. Por cada puerto abierto, ``identify_port_service(port)`` (servicio que
la convencion IANA espera ahi) y, si ``grab_banners`` es True,
``grab_service_banner(host, port, ...)`` (servicio/version REAL leido
del banner en vivo).
3. Si ``save`` es True, archiva una tabla de evidencia en OSINT via
``save_scan_to_osint`` con ``scan_type="port_services"``.
Nunca lanza excepciones: cualquier fallo se refleja en la clave ``status``
del dict devuelto.
Args:
host: hostname o IP objetivo (ej. "127.0.0.1", "scanme.nmap.org").
ports: especificacion de puertos, se pasa tal cual a scan_tcp_ports.
Acepta lista de ints ([22, 80, 443]), string preset "common"
(~30 puertos comunes), string rango "1-1024" o string CSV
"22,80,443" (con rangos mezclados).
timeout_s: timeout por conexion TCP del escaneo de puertos, en segundos.
Default 1.0.
workers: numero de hilos concurrentes del escaneo. Default 100.
grab_banners: si True (default) llama grab_service_banner por cada
puerto abierto para identificar el servicio/version real; si False
solo usa identify_port_service (sin tocar el servicio en vivo -> mas
rapido y mas sigiloso, sin segunda ronda de conexiones).
banner_timeout_s: timeout del grab de banner por puerto, en segundos.
Default 3.0. Solo aplica si grab_banners=True.
save: si True (default) archiva la evidencia en OSINT via
save_scan_to_osint (scan_type="port_services"); si False solo
ejecuta el escaneo y no toca el vault ni el service. Si el sink
falla, el resultado degrada sin romper (osint.status="error").
Returns:
dict de estado. Nunca lanza.
ok::
{
"status": "ok",
"host": <host>,
"ip": <ip resuelta>,
"open_ports": [22, 5432, 6379],
"services": [
{
"port": 22,
"expected_service": "ssh",
"expected_desc": "Secure Shell",
"actual_service": "ssh", # None si grab_banners=False
"product": "OpenSSH", # "" si no se grabo banner
"version": "9.6p1", # "" si no se grabo banner
"banner": "SSH-2.0-OpenSSH_9.6p1 ...", # "" si no se grabo
"match": True, # expected==actual; None si no se grabo
},
...
],
"saved": <dict de save_scan_to_osint> | None,
"raw": "# scan_port_services ...\nPORT EXPECTED ...",
}
error (el escaneo de puertos fallo: host no resuelve, spec invalida)::
{"status": "error", "stage": "scan", "scan": <dict del scan>}
"""
# 1. Escaneo de puertos. Si falla, propagamos sin continuar.
scan = scan_tcp_ports(host, ports=ports, timeout_s=timeout_s, workers=workers)
if scan.get("status") != "ok":
return {"status": "error", "stage": "scan", "scan": scan}
ip = scan.get("ip", "")
open_ports = list(scan.get("open") or [])
# 2. Por cada puerto abierto: servicio esperado (puro) + servicio real (banner).
# Secuencial a proposito (KISS): los puertos abiertos suelen ser pocos y
# cada grab ya tiene su propio timeout acotado.
services: list[dict] = []
for port in open_ports:
expected = identify_port_service(port, proto="tcp")
entry = {
"port": port,
"expected_service": expected.get("service"),
"expected_desc": expected.get("description"),
"actual_service": None,
"product": "",
"version": "",
"banner": "",
"match": None,
}
if grab_banners:
banner_res = grab_service_banner(
host, port, timeout_s=banner_timeout_s, send_probe=True
)
if banner_res.get("status") == "ok":
actual = banner_res.get("service")
entry["actual_service"] = actual
entry["product"] = banner_res.get("product", "")
entry["version"] = banner_res.get("version", "")
entry["banner"] = banner_res.get("banner", "")
# match solo es significativo si ambos servicios son concretos.
exp = expected.get("service")
entry["match"] = (
exp == actual
if actual and actual != "unknown" and exp and exp != "unknown"
else False
)
else:
# El grab fallo (timeout/refused): dejamos actual como "unknown"
# para distinguirlo de "no se intento" (None con grab_banners=False).
entry["actual_service"] = "unknown"
entry["match"] = False
services.append(entry)
raw = _build_raw(host, ip, services)
# 3. Archiva la evidencia en OSINT si procede (degrada sin romper).
saved = None
if save:
summary = {
"open_ports": open_ports,
"services_detected": [
f"{s['port']}:{s.get('actual_service') or s.get('expected_service')}"
for s in services
],
}
saved = save_scan_to_osint(
host,
"port_services",
raw,
summary=summary,
tool="scan_port_services",
)
return {
"status": "ok",
"host": host,
"ip": ip,
"open_ports": open_ports,
"services": services,
"saved": saved,
"raw": raw,
}
def _parse_cli(argv: list[str]) -> dict:
"""Parsea los args de CLI: <host> [ports] [--no-banners] [--no-save].
Devuelve un dict de kwargs para scan_port_services.
"""
positional: list[str] = []
grab_banners = True
save = True
for arg in argv:
if arg == "--no-banners":
grab_banners = False
elif arg == "--no-save":
save = False
else:
positional.append(arg)
return {"positional": positional, "grab_banners": grab_banners, "save": save}
if __name__ == "__main__":
import sys
parsed = _parse_cli(sys.argv[1:])
positional = parsed["positional"]
host = positional[0] if len(positional) >= 1 else "127.0.0.1"
ports = positional[1] if len(positional) >= 2 else "common"
try:
result = scan_port_services(
host,
ports=ports,
grab_banners=parsed["grab_banners"],
save=parsed["save"],
)
print("status:", result.get("status"))
if result.get("status") == "ok":
print(f"host: {result['host']} ({result['ip']})")
print("open_ports:", result["open_ports"])
print("--- services ---")
print(result["raw"])
saved = result.get("saved") or {}
if saved:
print("note_path:", saved.get("note_path"))
print("registered:", saved.get("registered"))
else:
print("error:", result.get("scan", {}).get("error"))
except Exception as exc: # noqa: BLE001 - smoke nunca debe romper
print("smoke exception (tolerada):", repr(exc))
@@ -0,0 +1,203 @@
"""Tests para el pipeline scan_port_services — SIN red externa ni service real.
El golden levanta un servidor TCP local efimero en 127.0.0.1 que emite un
banner SSH falso al conectar; el pipeline compone scan_tcp_ports +
identify_port_service + grab_service_banner contra ese puerto real. Asi se
ejercita la composicion end-to-end sin tocar internet. save=False en todos los
tests para no escribir en el vault OSINT ni hacer POST al service.
Para el error path, save_scan_to_osint se parchea sobre los globals del modulo
del pipeline (importlib + monkeypatch) por si acaso, pero con save=False nunca
debe invocarse.
"""
import importlib
import os
import socket
import sys
import threading
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
# Globals del modulo del pipeline (donde viven scan_tcp_ports, save_scan_to_osint...).
mod = importlib.import_module("pipelines.scan_port_services")
scan_port_services = mod.scan_port_services
def _start_banner_server(banner: bytes) -> tuple[socket.socket, int, threading.Thread]:
"""Levanta un servidor TCP efimero en 127.0.0.1 que emite `banner` por conexion.
Sirve en bucle hasta que el socket se cierra: el pipeline hace DOS conexiones
al puerto (el connect-scan de scan_tcp_ports + el grab de grab_service_banner),
asi que el servidor debe aceptar varias, no solo una.
Returns:
(server_socket, port, thread). El caller debe cerrar el socket al final.
"""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 0)) # puerto efimero asignado por el SO
srv.listen(8)
port = srv.getsockname()[1]
def serve():
# Bucle de accept: cada conexion recibe el banner y se cierra. El bucle
# termina cuando el caller cierra `srv` (accept lanza OSError). Tolerante
# a fallos: nunca rompe el test.
srv.settimeout(5.0)
while True:
try:
conn, _ = srv.accept()
except OSError:
break # socket cerrado por el caller -> fin del servidor
try:
conn.sendall(banner)
except OSError:
pass
finally:
conn.close()
t = threading.Thread(target=serve, daemon=True)
t.start()
return srv, port, t
# --- 1. Golden: scan + identify + grab de banner real en localhost -----------
def test_golden_scan_localhost_con_banner_real():
"""grab_banners=True: detecta el puerto abierto y lee el banner SSH falso."""
srv, port, thread = _start_banner_server(b"SSH-2.0-Test_1.0\r\n")
try:
result = scan_port_services(
"127.0.0.1",
ports=[port],
timeout_s=1.0,
grab_banners=True,
banner_timeout_s=2.0,
save=False,
)
assert result["status"] == "ok"
assert result["ip"] == "127.0.0.1"
# El puerto efimero aparece como abierto.
assert port in result["open_ports"]
# No se archivo en OSINT.
assert result["saved"] is None
# services trae una entrada para el puerto, con servicio esperado y real.
assert len(result["services"]) == 1
svc = result["services"][0]
assert svc["port"] == port
# expected_service viene de identify_port_service (puerto efimero alto ->
# no esta en la tabla IANA, asi que "unknown"; la clave debe existir).
assert "expected_service" in svc
# actual_service viene del banner SSH falso emitido por el servidor local.
assert svc["actual_service"] == "ssh"
assert "SSH-2.0-Test" in svc["banner"]
# match es bool cuando se grabo banner (no None).
assert svc["match"] in (True, False)
# raw es una tabla legible con el puerto.
assert isinstance(result["raw"], str)
assert str(port) in result["raw"]
assert "EXPECTED" in result["raw"]
finally:
srv.close()
thread.join(timeout=1.0)
# --- 2. grab_banners=False: solo servicio esperado, sin tocar el servicio ----
def test_grab_banners_false_solo_servicio_esperado():
"""grab_banners=False: identifica el esperado pero no abre segunda conexion."""
srv, port, thread = _start_banner_server(b"SSH-2.0-NoDebeLeerse\r\n")
try:
result = scan_port_services(
"127.0.0.1",
ports=[port],
timeout_s=1.0,
grab_banners=False,
save=False,
)
assert result["status"] == "ok"
assert port in result["open_ports"]
svc = result["services"][0]
# Sin grab: actual_service/version/banner quedan None/'' y match=None.
assert svc["actual_service"] is None
assert svc["banner"] == ""
assert svc["version"] == ""
assert svc["match"] is None
# expected_service si esta presente (de identify_port_service).
assert "expected_service" in svc
finally:
srv.close()
thread.join(timeout=1.0)
# --- 3. Error path: el escaneo de puertos falla -> error sin red -------------
def test_scan_fallido_propaga_error_sin_red():
"""Host que no resuelve: scan_tcp_ports da error y el pipeline lo propaga."""
save_called = {"n": 0}
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {"status": "ok"}
# Parcheamos el sink: aunque save=True, con scan fallido no debe invocarse.
import contextlib
original_save = mod.save_scan_to_osint
mod.save_scan_to_osint = fake_save
try:
result = scan_port_services(
"nohost.invalid.tld.example",
ports=[80],
timeout_s=0.5,
save=True,
)
finally:
mod.save_scan_to_osint = original_save
assert result["status"] == "error"
assert result["stage"] == "scan"
assert result["scan"]["status"] == "error"
# No se intento archivar nada.
assert save_called["n"] == 0
# Silencia un linter por contextlib import no usado fuera.
_ = contextlib
# --- 4. save=False: corre el scan completo pero NO archiva en OSINT -----------
def test_save_false_no_archiva_osint():
"""save=False sobre un puerto abierto real: services poblado, saved=None."""
save_called = {"n": 0}
def fake_save(*args, **kwargs): # pragma: no cover - no debe llamarse
save_called["n"] += 1
return {"status": "ok"}
srv, port, thread = _start_banner_server(b"SSH-2.0-Test_1.0\r\n")
original_save = mod.save_scan_to_osint
mod.save_scan_to_osint = fake_save
try:
result = scan_port_services(
"127.0.0.1",
ports=[port],
timeout_s=1.0,
grab_banners=True,
banner_timeout_s=2.0,
save=False,
)
finally:
mod.save_scan_to_osint = original_save
srv.close()
thread.join(timeout=1.0)
assert result["status"] == "ok"
assert port in result["open_ports"]
assert result["saved"] is None
# El sink nunca se invoco con save=False.
assert save_called["n"] == 0