#!/usr/bin/env python3
"""
Recolector de hardening para Linux — Ciberkit
===================================================

Reúne el estado de seguridad del equipo (kernel, SSH, cuentas, permisos, servicios,
cortafuegos, puertos abiertos, actualizaciones, registros…) y lo guarda en un JSON que
se evalúa en la web: Ciberkit → Hardening → Auditoría.

NO CAMBIA NADA en el sistema: solo lee. La evaluación y el script de corrección se generan
en la web, así puedes revisar cada cambio antes de aplicarlo.

    Úsalo SOLO en equipos propios o con autorización. Sin dependencias: Python 3.8+.

Ejemplos
--------
  sudo python3 recoger_linux.py                  # completo (recomendado)
  python3 recoger_linux.py                       # sin root: faltarán algunos datos
  sudo python3 recoger_linux.py --salida /tmp/hardening.json
"""

import argparse
import datetime as dt
import glob
import json
import os
import re
import shutil
import socket
import stat
import subprocess
import sys

FORMATO = "suite-redes.hardening/1"

# Parámetros que evalúa la web (assets/js/core/hardening.js; los tests comprueban que coinciden)
SYSCTL = [
    "net.ipv4.ip_forward", "net.ipv4.conf.all.accept_redirects", "net.ipv4.conf.all.send_redirects",
    "net.ipv4.conf.all.accept_source_route", "net.ipv4.conf.all.rp_filter", "net.ipv4.tcp_syncookies",
    "net.ipv4.conf.all.log_martians", "kernel.randomize_va_space", "kernel.kptr_restrict", "kernel.dmesg_restrict",
    "fs.suid_dumpable", "fs.protected_symlinks", "fs.protected_hardlinks", "kernel.yama.ptrace_scope",
]
ARCHIVOS = ["/etc/shadow", "/etc/passwd", "/etc/ssh/sshd_config", "/etc/crontab"]
SERVICIOS = [
    "telnet.socket", "rsh.socket", "rlogin.socket", "rexec.socket", "tftp.socket", "xinetd.service", "vsftpd.service",
    "avahi-daemon.service", "cups.service", "rpcbind.service", "rpcbind.socket", "nfs-server.service",
]

try:
    import pwd
    import grp
except ImportError:  # pragma: no cover - Windows (solo para poder importar en los tests)
    pwd = grp = None


# ───────────────────────── utilidades ─────────────────────────

def leer(ruta):
    try:
        with open(ruta, "r", encoding="utf-8", errors="replace") as fh:
            return fh.read()
    except OSError:
        return None


def ejecutar(cmd, timeout=60):
    """(código de salida, salida) o (None, None) si el programa no existe."""
    if not shutil.which(cmd[0]):
        return None, None
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return r.returncode, r.stdout
    except (OSError, subprocess.SubprocessError):
        return None, None


# ───────────────────────── analizadores (con tests) ─────────────────────────

def parse_clave_valor(texto, sep="="):
    """pwquality.conf, faillock.conf, login.defs: «clave = valor» o «CLAVE valor»."""
    out = {}
    for linea in (texto or "").splitlines():
        linea = linea.split("#", 1)[0].strip()
        if not linea:
            continue
        if sep in linea:
            k, v = linea.split(sep, 1)
        else:
            partes = linea.split(None, 1)
            if len(partes) != 2:
                continue
            k, v = partes
        out[k.strip()] = v.strip()
    return out


def parse_ss(texto):
    """Salida de «ss -tulnpH» → [{proto, direccion, puerto, proceso}]."""
    puertos = []
    for linea in (texto or "").splitlines():
        p = linea.split()
        if len(p) < 5 or p[0] not in ("tcp", "udp"):
            continue
        local = p[4]
        m = re.match(r"^(.*):(\d+|\*)$", local)
        if not m or m.group(2) == "*":
            continue
        direccion = m.group(1).strip("[]")
        direccion = re.sub(r"%\w+$", "", direccion)  # 0.0.0.0%eth0
        proc = re.search(r'users:\(\("([^"]+)"', linea)
        puertos.append({"proto": p[0], "direccion": direccion or "*", "puerto": int(m.group(2)), "proceso": proc.group(1) if proc else ""})
    unicos = {(x["proto"], x["direccion"], x["puerto"]): x for x in puertos}
    return sorted(unicos.values(), key=lambda x: (x["puerto"], x["proto"]))


def parse_montajes(texto):
    """/proc/mounts → opciones de /tmp y /dev/shm (None si no son montajes separados)."""
    out = {"/tmp": None, "/dev/shm": None}
    for linea in (texto or "").splitlines():
        p = linea.split()
        if len(p) >= 4 and p[1] in out:
            out[p[1]] = p[3]
    return out


def uid0(passwd):
    return [l.split(":")[0] for l in (passwd or "").splitlines() if l.count(":") >= 6 and l.split(":")[2] == "0"]


def cuentas_vacias(shadow):
    if shadow is None:
        return None
    return sum(1 for l in shadow.splitlines() if l.count(":") >= 8 and l.split(":")[1] == "")


def nopasswd(textos):
    n = 0
    for t in textos:
        for linea in (t or "").splitlines():
            linea = linea.split("#", 1)[0]
            if "NOPASSWD" in linea:
                n += 1
    return n


def sshd_desde_archivos(principal, extra):
    """Une sshd_config con sus Include (en su posición) para respetar «el primer valor gana»."""
    if principal is None:
        return None
    out = []
    for linea in principal.splitlines():
        m = re.match(r"^\s*Include\s+(.+)$", linea, re.I)
        if m:
            for patron in m.group(1).split():
                patron = patron if patron.startswith("/") else os.path.join("/etc/ssh", patron)
                for ruta in sorted(glob.glob(patron)):
                    out.append(f"# --- {ruta}")
                    out.append(extra.get(ruta) if ruta in extra else (leer(ruta) or ""))
            continue
        out.append(linea)
    return "\n".join(out)


def apt_pendientes(simulacion):
    lineas = [l for l in (simulacion or "").splitlines() if l.startswith("Inst ")]
    return len(lineas), sum(1 for l in lineas if re.search(r"-security|Debian-Security", l))


# ───────────────────────── recolección ─────────────────────────

def recoger():
    es_root = hasattr(os, "geteuid") and os.geteuid() == 0
    f = {"formato": FORMATO, "plataforma": "linux", "equipo": socket.gethostname(), "root": es_root,
         "generado": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")}

    osr = parse_clave_valor(leer("/etc/os-release"))
    f["so"] = {"nombre": osr.get("PRETTY_NAME", "").strip('"'), "id": osr.get("ID", "").strip('"'),
               "version": osr.get("VERSION_ID", "").strip('"'), "kernel": os.uname().release if hasattr(os, "uname") else ""}

    f["sysctl"] = {}
    for clave in SYSCTL:
        v = leer("/proc/sys/" + clave.replace(".", "/"))
        f["sysctl"][clave] = v.strip() if v is not None else None

    codigo, salida = ejecutar(["sshd", "-T"]) if es_root else (None, None)
    if codigo == 0 and salida:
        f["sshd"] = {"texto": salida, "efectivo": True}
    else:
        texto = sshd_desde_archivos(leer("/etc/ssh/sshd_config"), {})
        f["sshd"] = {"texto": texto, "efectivo": False} if texto is not None else None

    f["archivos"] = {}
    for ruta in ARCHIVOS:
        try:
            st = os.stat(ruta)
            f["archivos"][ruta] = {
                "modo": format(stat.S_IMODE(st.st_mode), "04o"),
                "propietario": pwd.getpwuid(st.st_uid).pw_name if pwd else str(st.st_uid),
                "grupo": grp.getgrgid(st.st_gid).gr_name if grp else str(st.st_gid),
            }
        except FileNotFoundError:
            f["archivos"][ruta] = None
        except (OSError, KeyError):
            pass

    f["cuentas"] = {"uid0": uid0(leer("/etc/passwd")), "vacias": cuentas_vacias(leer("/etc/shadow"))}
    pq = leer("/etc/security/pwquality.conf")
    pq_d = "".join(leer(p) or "" for p in sorted(glob.glob("/etc/security/pwquality.conf.d/*.conf")))
    f["pwquality"] = parse_clave_valor((pq or "") + "\n" + pq_d) if (pq or pq_d) else None
    if f["pwquality"] is not None and "minlen" not in f["pwquality"]:
        f["pwquality"]["minlen"] = "8"  # valor por defecto de pwquality
    fl = leer("/etc/security/faillock.conf")
    fl_kv = parse_clave_valor(fl) if fl else {}
    pam = " ".join(leer(p) or "" for p in glob.glob("/etc/pam.d/*"))
    f["faillock"] = {"deny": fl_kv.get("deny", "3")} if ("pam_faillock" in pam or "pam_tally2" in pam) else None
    sudoers = [leer("/etc/sudoers")] + [leer(p) for p in glob.glob("/etc/sudoers.d/*")]
    f["sudo"] = {"nopasswd": nopasswd(sudoers) if sudoers[0] is not None else None}

    f["servicios"] = {}
    for unidad in SERVICIOS:
        codigo, salida = ejecutar(["systemctl", "show", "-p", "LoadState,ActiveState,UnitFileState", unidad])
        if codigo is None:
            f["servicios"] = None
            break
        kv = parse_clave_valor(salida)
        if kv.get("LoadState") == "loaded":
            f["servicios"][unidad] = {"activo": kv.get("ActiveState") == "active", "habilitado": kv.get("UnitFileState") in ("enabled", "static")}

    fw = {"ufw": None, "firewalld": None, "nft_reglas": None, "iptables_reglas": None}
    c, s = ejecutar(["ufw", "status"])
    if c is not None and s:
        fw["ufw"] = "active" if "Status: active" in s else "inactive"
    c, s = ejecutar(["firewall-cmd", "--state"])
    if c is not None:
        fw["firewalld"] = (s or "").strip() or "not running"
    c, s = ejecutar(["nft", "list", "ruleset"])
    if c == 0:
        fw["nft_reglas"] = sum(1 for l in s.splitlines() if re.search(r"\b(accept|drop|reject)\b", l))
    c, s = ejecutar(["iptables", "-S"])
    if c == 0:
        fw["iptables_reglas"] = sum(1 for l in s.splitlines() if l.startswith("-A"))
    f["firewall"] = fw

    c, s = ejecutar(["ss", "-tulnpH"])
    f["puertos"] = parse_ss(s) if c == 0 else None
    f["montajes"] = parse_montajes(leer("/proc/mounts"))

    mac = {"apparmor": None, "selinux": None}
    c, _ = ejecutar(["aa-status", "--enabled"])
    if c is not None:
        mac["apparmor"] = "enabled" if c == 0 else "disabled"
    c, s = ejecutar(["getenforce"])
    if c == 0:
        mac["selinux"] = s.strip()
    f["mac"] = mac

    c, s = ejecutar(["timedatectl", "show", "-p", "NTPSynchronized", "--value"])
    f["tiempo"] = {"sincronizado": (s.strip() == "yes") if c == 0 else None}

    def activo(u):
        c, s = ejecutar(["systemctl", "is-active", u])
        return c is not None and (s or "").strip() == "active"
    jconf = (leer("/etc/systemd/journald.conf") or "") + "".join(leer(p) or "" for p in glob.glob("/etc/systemd/journald.conf.d/*.conf"))
    f["servicios_registro"] = {"auditd": activo("auditd"), "rsyslog": activo("rsyslog") or activo("syslog-ng"),
                               "journald_persistente": os.path.isdir("/var/log/journal") or bool(re.search(r"^\s*Storage\s*=\s*persistent", jconf, re.M))}

    upd = {"gestor": None, "pendientes": None, "seguridad": None, "automaticas": None, "reinicio": None}
    if shutil.which("apt-get"):
        upd["gestor"] = "apt"
        c, s = ejecutar(["apt-get", "-s", "-o", "Debug::NoLocking=1", "upgrade"], timeout=180)
        if c == 0:
            upd["pendientes"], upd["seguridad"] = apt_pendientes(s)
        auto = leer("/etc/apt/apt.conf.d/20auto-upgrades") or ""
        upd["automaticas"] = bool(re.search(r'Unattended-Upgrade\s+"1"', auto))
        upd["reinicio"] = os.path.exists("/var/run/reboot-required")
    elif shutil.which("dnf"):
        upd["gestor"] = "dnf"
        c, s = ejecutar(["dnf", "-q", "updateinfo", "list", "--security"], timeout=180)
        if c == 0:
            upd["seguridad"] = len([l for l in s.splitlines() if l.strip()])
        c, s = ejecutar(["dnf", "-q", "check-update"], timeout=180)
        if c in (0, 100):
            upd["pendientes"] = len([l for l in (s or "").splitlines() if re.match(r"^\S+\.\S+\s", l)])
        c1, s1 = ejecutar(["systemctl", "is-enabled", "dnf-automatic-install.timer"])
        c2, s2 = ejecutar(["systemctl", "is-enabled", "dnf-automatic.timer"])
        upd["automaticas"] = "enabled" in ((s1 or "") + (s2 or ""))
        c, _ = ejecutar(["needs-restarting", "-r"])
        upd["reinicio"] = (c == 1) if c is not None else None
    f["actualizaciones"] = upd

    grub = None
    for ruta in ("/boot/grub/grub.cfg", "/boot/grub2/grub.cfg", "/boot/efi/EFI/redhat/grub.cfg"):
        t = leer(ruta)
        if t is not None:
            grub = "password_pbkdf2" in t or re.search(r"^\s*password\s", t, re.M) is not None
            break
    f["grub"] = {"password": grub}
    return f


def preparar_salida():
    for flujo in (sys.stdout, sys.stderr):
        try:
            flujo.reconfigure(encoding="utf-8", errors="replace") if not flujo.isatty() else flujo.reconfigure(errors="replace")
        except (AttributeError, ValueError):
            pass


def main(argv=None):
    preparar_salida()
    ap = argparse.ArgumentParser(description="Recoge el estado de hardening del equipo (solo lectura).")
    ap.add_argument("--salida", help="ruta del JSON (por defecto hardening-<equipo>.json)")
    args = ap.parse_args(argv)
    f = recoger()
    ruta = args.salida or f"hardening-{re.sub(r'[^A-Za-z0-9_.-]', '_', f['equipo'])}.json"
    with open(ruta, "w", encoding="utf-8") as fh:
        json.dump(f, fh, ensure_ascii=False, indent=2)
    print(f"{f['so']['nombre'] or 'Linux'} ({f['equipo']}): datos guardados en {ruta}")
    if not f["root"]:
        print("Aviso: sin root faltan datos (configuración efectiva de SSH, /etc/shadow, cortafuegos, sudoers…). Ejecútalo con sudo.")
    print("Ábrelo en la web (Hardening → Auditoría) para ver la nota, los fallos y el script de corrección.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
