935008ec3f
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>
145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
"""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"))
|