#!/usr/bin/env python3
"""controlla_eval_pickle.py - ai riparo

Due funzioni, solo libreria standard (Python 3.11+):

  --demo     mostra in una cartella temporanea perche' eval() e pickle sono
             pericolosi con dati non fidati, e come si comportano le
             alternative sicure (ast.literal_eval, json, tomllib, hmac).
  percorso   analizza i file .py (senza eseguirli) e segnala eval, exec,
             pickle/marshal/shelve in lettura e yaml.load senza SafeLoader.

Codici di uscita: 0 nessuna segnalazione, 1 segnalazioni, 2 percorso inesistente.
"""
import argparse
import ast
import hashlib
import hmac
import json
import os
import pickle
import shutil
import sys
import tempfile
from pathlib import Path

VERSIONE = "1.0.0"
ESCLUSE = {".venv", "venv", "env", "node_modules", ".git", "__pycache__",
           "site-packages", "build", "dist"}


# --------------------------------------------------------------- demo
class Trappola:
    """Oggetto che, quando viene de-serializzato, crea un file."""
    def __init__(self, percorso):
        self.percorso = percorso

    def __reduce__(self):
        codice = f"open({self.percorso!r}, 'w').write('ESEGUITO')"
        return (exec, (codice,))


def demo():
    cartella = Path(tempfile.mkdtemp(prefix="airiparo_eval_"))
    segnale = cartella / "eseguito.txt"
    try:
        def creato():
            esiste = segnale.exists()
            if esiste:
                segnale.unlink()
            return "SI" if esiste else "no"

        malevolo = f"__import__('pathlib').Path({str(segnale)!r}).write_text('x')"
        print("Input simulato: una riga di \"config\" che in realta' e' codice")
        print("  __import__('pathlib').Path('<temp>/eseguito.txt').write_text('x')\n")

        print("1) VULNERABILE: eval() sul testo")
        try:
            eval(malevolo)
        except Exception as e:  # noqa: BLE001
            print("   errore:", type(e).__name__)
        print("   file eseguito.txt creato?", creato())

        print("\n2) SICURO: ast.literal_eval() sullo stesso testo")
        try:
            ast.literal_eval(malevolo)
        except ValueError as e:
            print("   rifiutato: ValueError,", str(e).split(" on line")[0])
        print("   file eseguito.txt creato?", creato())
        print("   literal_eval su dati veri:",
              ast.literal_eval("{'porta': 8080, 'debug': False}"))

        print("\n3) VULNERABILE: pickle.loads() su un file manomesso")
        dati = pickle.dumps(Trappola(str(segnale)))
        pickle.loads(dati)
        print("   file eseguito.txt creato?", creato())

        print("\n4) SICURO: json.loads() - accetta solo dati")
        cfg = json.loads('{"porta": 8080, "debug": false, "clienti": ["rossi", "bianchi"]}')
        print("   ", cfg)
        try:
            json.loads(malevolo)
        except json.JSONDecodeError as e:
            print("   testo malevolo rifiutato:", e.msg)

        print("\n5) SICURO: tomllib per i file di configurazione")
        import tomllib
        toml = 'porta = 8080\ndebug = false\n[database]\npercorso = "dati/clienti.db"\n'
        print("   ", tomllib.loads(toml))

        print("\n6) SE PICKLE E' INEVITABILE: firma HMAC prima di caricare")
        chiave = os.urandom(32)
        buono = pickle.dumps({"porta": 8080})
        firma = hmac.new(chiave, buono, hashlib.sha256).digest()

        def carica_firmato(blob, firma_attesa):
            calcolata = hmac.new(chiave, blob, hashlib.sha256).digest()
            if not hmac.compare_digest(calcolata, firma_attesa):
                raise ValueError("firma non valida: file non caricato")
            return pickle.loads(blob)

        print("   file originale:", carica_firmato(buono, firma))
        try:
            carica_firmato(dati, firma)
        except ValueError as e:
            print("   file manomesso:", e)
        print("   file eseguito.txt creato?", creato())
    finally:
        shutil.rmtree(cartella, ignore_errors=True)
    print("\nCartella temporanea eliminata.")
    return 0


# --------------------------------------------------------------- scanner
def nome_chiamata(nodo):
    f = nodo.func
    if isinstance(f, ast.Name):
        return f.id
    if isinstance(f, ast.Attribute):
        base = f.value
        if isinstance(base, ast.Name):
            return f"{base.id}.{f.attr}"
        return f"?.{f.attr}"
    return ""


def yaml_sicuro(nodo):
    for kw in nodo.keywords:
        if kw.arg == "Loader":
            v = ast.unparse(kw.value)
            return "Safe" in v
    if len(nodo.args) >= 2:
        return "Safe" in ast.unparse(nodo.args[1])
    return False


def analizza(path):
    try:
        albero = ast.parse(path.read_text(encoding="utf-8", errors="replace"),
                           filename=str(path))
    except SyntaxError as e:
        return [("ERRORE", e.lineno or 0, f"sintassi non valida: {e.msg}")]
    out = []
    for n in ast.walk(albero):
        if not isinstance(n, ast.Call):
            continue
        nome = nome_chiamata(n)
        fisso = bool(n.args) and isinstance(n.args[0], ast.Constant)
        if nome in ("eval", "exec"):
            if fisso:
                out.append(("MEDIO", n.lineno, f"{nome}() con stringa fissa: serve davvero?"))
            else:
                out.append(("ALTO", n.lineno, f"{nome}() su un valore calcolato a runtime"))
        elif nome in ("pickle.load", "pickle.loads", "cPickle.load", "cPickle.loads",
                      "dill.load", "dill.loads", "marshal.load", "marshal.loads",
                      "joblib.load", "?.Unpickler", "pickle.Unpickler"):
            out.append(("ALTO", n.lineno, f"{nome}(): esegue codice se il file e' manomesso"))
        elif nome == "shelve.open":
            out.append(("MEDIO", n.lineno, "shelve.open(): usa pickle sotto"))
        elif nome in ("yaml.load", "yaml.load_all", "yaml.unsafe_load", "yaml.full_load"):
            if nome.startswith("yaml.load") and yaml_sicuro(n):
                continue
            out.append(("ALTO", n.lineno, f"{nome}() senza SafeLoader: usa yaml.safe_load()"))
        elif nome in ("ast.literal_eval",):
            continue
    return sorted(out, key=lambda x: x[1])


def file_py(radice, escluse):
    if radice.is_file():
        yield radice
        return
    for cart, sotto, files in os.walk(radice):
        sotto[:] = sorted(d for d in sotto if d not in escluse)
        for f in sorted(files):
            if f.endswith(".py"):
                yield Path(cart) / f


def scansiona(percorso, escluse):
    radice = Path(percorso)
    if not radice.exists():
        print(f"Percorso inesistente: {percorso}", file=sys.stderr)
        return 2
    conta = {"ALTO": 0, "MEDIO": 0, "ERRORE": 0}
    n_file = 0
    io = Path(__file__).resolve()
    for f in file_py(radice, escluse):
        if f.resolve() == io:
            continue
        n_file += 1
        rel = f.relative_to(radice) if radice.is_dir() else f.name
        for livello, riga, msg in analizza(f):
            conta[livello] += 1
            print(f"[{livello}] {Path(rel).as_posix()}:{riga}  {msg}")
    tot = sum(conta.values())
    print(f"\nFile .py analizzati: {n_file}")
    print(f"Segnalazioni: {tot} (ALTO {conta['ALTO']}, MEDIO {conta['MEDIO']}, ERRORE {conta['ERRORE']})")
    return 1 if tot else 0


def main():
    p = argparse.ArgumentParser(description="Demo e scanner per eval/exec/pickle/yaml.load (ai riparo)")
    p.add_argument("percorso", nargs="?", default=".", help="cartella o file .py (default: .)")
    p.add_argument("--escludi", action="append", default=[], metavar="NOME",
                   help="cartella da saltare (ripetibile)")
    p.add_argument("--demo", action="store_true", help="dimostrazione in cartella temporanea")
    p.add_argument("--version", action="version", version=f"%(prog)s {VERSIONE}")
    a = p.parse_args()
    if sys.version_info < (3, 11):
        print("Serve Python 3.11 o successivo (tomllib).", file=sys.stderr)
        return 2
    if a.demo:
        return demo()
    return scansiona(a.percorso, ESCLUSE | set(a.escludi))


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