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:
@@ -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"
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user