"""Lookup WHOIS de un dominio o IP via el CLI `whois` del sistema. 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 re import subprocess def _first_match(raw: str, *labels: str) -> str | None: """Devuelve el valor de la primera linea cuyo label coincide (case-insensitive). 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 _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 parse_whois_raw(raw: str, target: str) -> dict: """Parsea best-effort el texto crudo de `whois` en campos normalizados. 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: raw: stdout completo del comando `whois`. target: dominio o IP consultado (se incluye en el dict de salida). Returns: Dict con status "ok", el raw completo y los campos parseados. """ 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") ], } def whois_lookup(target: str, timeout_s: int = 30) -> dict: """Ejecuta `whois ` y parsea best-effort los campos de registro. Funcion IMPURA: lanza el CLI `whois` como subproceso. Captura el stdout completo (siempre presente en ``raw``) y extrae campos comunes de forma tolerante. Devuelve un dict; nunca lanza: los errores se reportan como ``{"status": "error", "error": "..."}``. Args: target: Dominio (ej. ``"google.com"``) o direccion IP a consultar. timeout_s: Segundos maximo de espera del subproceso (default 30). Returns: Dict de exito:: { "status": "ok", "target": , "raw": , "registrar": str | None, "registrant_country": str | None, "creation_date": str | None, "expiry_date": str | None, "updated_date": str | None, "name_servers": [str, ...], } Para IPs varios campos de dominio quedan None. En fallo:: {"status": "error", "error": "", "target": } """ if not target or not target.strip(): return {"status": "error", "error": "whois_lookup: target vacio", "target": target} target = target.strip() try: proc = subprocess.run( ["whois", target], capture_output=True, text=True, timeout=timeout_s, ) except FileNotFoundError: return { "status": "error", "error": "whois_lookup: binario 'whois' no encontrado (instala con `apt install whois`)", "target": target, } 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} 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"))