#!/usr/bin/env python3
"""
Inventario de tareas programadas — Ciberkit
=================================================

Recoge TODAS las tareas programadas de un equipo Linux (o macOS) y las guarda en un
JSON que se abre en la web (Ciberkit → Cron y tareas → Auditor):

  · /etc/crontab y /etc/cron.d/*            (formato del sistema, con usuario)
  · crontabs de todos los usuarios           (/var/spool/cron, requiere root)
  · /etc/cron.hourly|daily|weekly|monthly/   (quién puede modificar esos scripts)
  · timers de systemd y el comando que lanza cada uno

Además muestra en pantalla los indicadores de compromiso que encuentre (descargas que
se ejecutan, shells inversas, mineros, rutas temporales…), con la misma lista que la web.

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

Ejemplos
--------
  sudo python3 inventario_tareas.py                  # todo el equipo
  python3 inventario_tareas.py                       # sin root: solo tu crontab
  sudo python3 inventario_tareas.py --salida /tmp/tareas.json
"""

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

FORMATO = "suite-redes.tareas/1"

try:
    import pwd  # solo existe en Linux/macOS
except ImportError:  # pragma: no cover - Windows
    pwd = None

# Misma lista que assets/js/core/crontab.js → SECURITY_PATTERNS (los tests comprueban que coinciden).
PATRONES = [
    ("descarga-ejecuta", "critico", r"(curl|wget|fetch)\b[^|;&]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b", "Descarga código de Internet y lo ejecuta directamente."),
    ("descarga-ejecuta-2", "critico", r'''(ba|z|da)?sh\s+(-c\s+)?["']?(<\(|\$\()\s*(curl|wget)''', "Ejecuta lo que devuelve una descarga (sh <(curl …))."),
    ("shell-inversa", "critico", r'''/dev/(tcp|udp)/|\bnc(at)?\b[^;|]*\s-[ec]\s|\bsocat\b[^;|]*exec|\bbash\s+-i\b|\bmkfifo\b.*\b(nc|ncat|telnet)\b|python[0-9.]*\s+-c\s+["'][^"']*socket''', "Patrón de shell inversa."),
    ("base64-ejecuta", "critico", r"base64\s+(-d|--decode)[^;]*\|\s*(ba|z)?sh|echo\s+[A-Za-z0-9+/=]{40,}\s*\|\s*base64", "Código ofuscado en Base64 que se ejecuta."),
    ("ejecuta-temporal", "critico", r"(^|[;&|]\s*|(ba|da|z)?sh\s+|python[0-9.]*\s+|perl\s+)(/tmp|/var/tmp|/dev/shm)/", "Ejecuta un programa desde una carpeta temporal."),
    ("archivo-oculto", "aviso", r"(^|[;&|]\s*|(ba|da|z)?sh\s+)\S*/\.(?!local/|cargo/|nvm/|pyenv/|rbenv/|deno/|bun/)[A-Za-z0-9_]", "Ejecuta un archivo oculto."),
    ("minero", "critico", r"xmrig|minerd|cpuminer|stratum\+(tcp|ssl)|cryptonight|nicehash|kdevtmpfsi|kinsing", "Indicadores de minería de criptomonedas."),
    ("borra-rastros", "critico", r"history\s+-c|unset\s+HISTFILE|>\s*/var/log/(auth|secure|syslog|messages|wtmp|btmp|lastlog)|rm\s+(-\w+\s+)*\S*/var/log/(auth|secure|wtmp|btmp|lastlog)", "Borra registros o el historial."),
    ("cuentas", "critico", r"\buseradd\b|\badduser\b|usermod\s+-aG\s+(sudo|wheel)|>>?\s*/etc/(passwd|shadow|sudoers)|authorized_keys", "Crea usuarios, da permisos o añade claves SSH."),
    ("permisos-abiertos", "aviso", r"chmod\s+(-R\s+)?(0?777|a\+rwx|o\+w)\b", "Da permisos de escritura a todo el mundo."),
    ("tls-desactivado", "aviso", r"curl\b[^|;]*\s(-k\b|--insecure)|wget\b[^|;]*--no-check-certificate", "Desactiva la verificación TLS."),
]
COMPILADOS = [(i, sev, re.compile(src, re.I), txt) for i, sev, src, txt in PATRONES]


def indicadores(comando):
    return [(i, sev, txt) for i, sev, rx, txt in COMPILADOS if rx.search(comando or "")]


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):
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
        return r.stdout if r.returncode == 0 else None
    except (OSError, subprocess.SubprocessError):
        return None


def propietario(st):
    try:
        return pwd.getpwuid(st.st_uid).pw_name
    except (KeyError, AttributeError, TypeError):
        return str(st.st_uid)


def recoger_crontabs(avisos):
    out = []
    for ruta in ["/etc/crontab", *sorted(glob.glob("/etc/cron.d/*"))]:
        if os.path.basename(ruta).startswith(".") or ruta.endswith((".dpkg-dist", ".dpkg-old", "~")):
            continue
        texto = leer(ruta)
        if texto is not None:
            out.append({"origen": ruta, "sistema": True, "contenido": texto})
    usuarios = []
    for carpeta in ("/var/spool/cron/crontabs", "/var/spool/cron", "/usr/lib/cron/tabs"):
        if not os.path.isdir(carpeta):
            continue
        try:
            nombres = sorted(os.listdir(carpeta))
        except PermissionError:
            avisos.append(f"Sin permiso para leer {carpeta}: ejecuta con sudo para ver los crontabs de todos los usuarios.")
            continue
        for nombre in nombres:
            ruta = os.path.join(carpeta, nombre)
            if os.path.isfile(ruta) and not nombre.startswith("."):
                texto = leer(ruta)
                if texto is not None:
                    usuarios.append(nombre)
                    out.append({"origen": f"crontab de {nombre}", "sistema": False, "contenido": texto})
    if not usuarios:
        propio = ejecutar(["crontab", "-l"])
        if propio:
            yo = pwd.getpwuid(os.getuid()).pw_name if pwd else os.environ.get("USER", "yo")
            out.append({"origen": f"crontab de {yo}", "sistema": False, "contenido": propio})
    return out


def recoger_periodicos():
    out = []
    for carpeta in sorted(glob.glob("/etc/cron.hourly") + glob.glob("/etc/cron.daily") + glob.glob("/etc/cron.weekly") + glob.glob("/etc/cron.monthly")):
        for nombre in sorted(os.listdir(carpeta)):
            ruta = os.path.join(carpeta, nombre)
            try:
                st = os.lstat(ruta)
            except OSError:
                continue
            if not stat.S_ISREG(st.st_mode) or nombre.startswith("."):
                continue
            out.append({
                "carpeta": os.path.basename(carpeta),
                "archivo": ruta,
                "propietario": propietario(st),
                "permisos": stat.filemode(st.st_mode),
                "escribible_por_otros": bool(st.st_mode & stat.S_IWOTH),
                "escribible_por_grupo": bool(st.st_mode & stat.S_IWGRP),
                "contenido": (leer(ruta) or "")[:4000],
            })
    return out


def recoger_timers(avisos):
    if not shutil.which("systemctl"):
        return []
    salida = ejecutar(["systemctl", "list-timers", "--all", "--no-pager", "--no-legend", "--plain"])
    if salida is None:
        avisos.append("No se pudieron listar los timers de systemd.")
        return []
    timers = []
    for linea in salida.splitlines():
        partes = linea.split()
        if len(partes) < 2 or not partes[-2].endswith(".timer"):
            continue
        unidad, activa = partes[-2], partes[-1]
        exec_start = ejecutar(["systemctl", "show", "-p", "ExecStart", "--value", activa]) or ""
        m = re.search(r"argv\[\]=([^;]*)", exec_start)
        timers.append({
            "unit": unidad,
            "activates": activa,
            "calendario": (ejecutar(["systemctl", "show", "-p", "TimersCalendar", "--value", unidad]) or "").strip()[:200],
            "comando": (m.group(1).strip() if m else exec_start.strip())[:500],
            "resto": " ".join(partes[:-2]),
        })
    return timers


def resumen(inv):
    hallazgos = []
    for c in inv["crontabs"]:
        for n, linea in enumerate(c["contenido"].splitlines(), 1):
            t = linea.strip()
            if t and not t.startswith("#"):
                for i, sev, txt in indicadores(t):
                    hallazgos.append((sev, f"{c['origen']}:{n}", txt, t))
    for p in inv["scripts_periodicos"]:
        if p["escribible_por_otros"] or p["propietario"] != "root":
            hallazgos.append(("critico", p["archivo"], f"Lo ejecuta root pero puede modificarlo {'cualquiera' if p['escribible_por_otros'] else p['propietario']}.", p["permisos"]))
        for i, sev, txt in indicadores(p["contenido"]):
            hallazgos.append((sev, p["archivo"], txt, ""))
    for t in inv["timers_systemd"]:
        for i, sev, txt in indicadores(t["comando"]):
            hallazgos.append((sev, t["unit"], txt, t["comando"]))
    return hallazgos


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


def main(argv=None):
    preparar_salida()
    ap = argparse.ArgumentParser(description="Inventario de tareas programadas (cron, cron.d, cron.daily y timers de systemd).")
    ap.add_argument("--salida", help="ruta del JSON (por defecto tareas-<equipo>.json)")
    args = ap.parse_args(argv)

    equipo = socket.gethostname()
    avisos = []
    if hasattr(os, "geteuid") and os.geteuid() != 0:
        avisos.append("Ejecutado sin root: solo se ve tu propio crontab y los archivos legibles.")
    inv = {
        "formato": FORMATO,
        "equipo": equipo,
        "plataforma": platform.system().lower(),
        "generado": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
        "crontabs": recoger_crontabs(avisos),
        "scripts_periodicos": recoger_periodicos(),
        "timers_systemd": recoger_timers(avisos),
        "avisos": avisos,
    }
    ruta = args.salida or f"tareas-{re.sub(r'[^A-Za-z0-9_.-]', '_', equipo)}.json"
    with open(ruta, "w", encoding="utf-8") as fh:
        json.dump(inv, fh, ensure_ascii=False, indent=2)

    print(f"Equipo {equipo}: {len(inv['crontabs'])} crontabs, {len(inv['scripts_periodicos'])} scripts en cron.*, {len(inv['timers_systemd'])} timers de systemd.")
    for a in avisos:
        print(f"  Aviso: {a}")
    hallazgos = resumen(inv)
    if hallazgos:
        print(f"\nIndicadores encontrados ({len(hallazgos)}):")
        for sev, donde, txt, linea in sorted(hallazgos, key=lambda h: h[0] != "critico"):
            print(f"  {sev.upper():<8} {donde}: {txt}")
            if linea:
                print(f"           {linea[:160]}")
    else:
        print("\nSin indicadores de compromiso conocidos.")
    print(f"\nGuardado en {ruta}. Ábrelo en la web (Cron y tareas → Auditor) para el análisis completo y el mapa semanal.")
    return 1 if any(h[0] == "critico" for h in hallazgos) else 0


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