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