#!/usr/bin/env python3
"""controlla_subprocess.py - ai riparo (airiparo.it)

Due funzioni, solo libreria standard, nessuna connessione di rete:

  --demo      mostra la differenza tra shell=True con input esterno e
              lista di argomenti, usando un comando innocuo (l'interprete
              Python) in una cartella temporanea che viene poi cancellata.
  PERCORSO    analizza i file .py di una cartella (o un singolo file) e
              segnala subprocess con shell=True, os.system e os.popen.

Lo scanner legge i file senza eseguirli (usa il modulo ast).
Codice di uscita: 0 nessuna segnalazione, 1 almeno una, 2 errore d'uso.

Licenza MIT.
"""

import argparse
import ast
import os
import subprocess
import sys
import tempfile
from pathlib import Path

VERSIONE = "1.0.0"
ESCLUSE_DEFAULT = {".venv", "venv", "env", "node_modules", ".git", "__pycache__", "site-packages", "build", "dist"}
FUNZIONI_SUBPROCESS = {"run", "call", "check_call", "check_output", "Popen", "getoutput", "getstatusoutput"}


# ---------------------------------------------------------------- demo
def demo() -> int:
    windows = os.name == "nt"
    separatore = "&" if windows else ";"
    nome_file = f"foto.jpg {separatore} echo INIETTATO> iniettato.txt"
    stampa = "import sys; print('argomento ricevuto:', sys.argv[1:])"

    print(f"Sistema: {'Windows (cmd.exe)' if windows else 'POSIX (/bin/sh)'}")
    print(f"Input simulato dell'utente: {nome_file!r}\n")

    with tempfile.TemporaryDirectory(prefix="demo-subprocess-") as cartella:
        bersaglio = Path(cartella) / "iniettato.txt"

        print("1) VULNERABILE: stringa + shell=True")
        comando = f'"{sys.executable}" -c "{stampa}" {nome_file}'
        r = subprocess.run(comando, shell=True, cwd=cartella, capture_output=True, text=True, timeout=30)
        print("   output:", r.stdout.strip() or "(vuoto)")
        print("   file iniettato.txt creato?", "SI" if bersaglio.exists() else "no")
        if bersaglio.exists():
            bersaglio.unlink()

        print("\n2) SICURO: lista di argomenti, shell=False (default)")
        r = subprocess.run([sys.executable, "-c", stampa, nome_file], cwd=cartella,
                           capture_output=True, text=True, timeout=30, check=True)
        print("   output:", r.stdout.strip())
        print("   file iniettato.txt creato?", "SI" if bersaglio.exists() else "no")

        if not windows:
            import shlex
            print("\n3) SE LA SHELL SERVE DAVVERO (solo POSIX): shlex.quote()")
            comando = f"{shlex.quote(sys.executable)} -c {shlex.quote(stampa)} {shlex.quote(nome_file)}"
            r = subprocess.run(comando, shell=True, cwd=cartella, capture_output=True, text=True, timeout=30, check=True)
            print("   output:", r.stdout.strip())
            print("   file iniettato.txt creato?", "SI" if bersaglio.exists() else "no")
    print("\nCartella temporanea eliminata.")
    return 0


# ------------------------------------------------------------- scanner
def nome_chiamata(nodo: ast.Call) -> str:
    f = nodo.func
    parti = []
    while isinstance(f, ast.Attribute):
        parti.append(f.attr)
        f = f.value
    if isinstance(f, ast.Name):
        parti.append(f.id)
    return ".".join(reversed(parti))


def e_costante(nodo) -> bool:
    if isinstance(nodo, ast.Constant):
        return True
    if isinstance(nodo, (ast.List, ast.Tuple)):
        return all(isinstance(e, ast.Constant) for e in nodo.elts)
    return False


def analizza_file(percorso: Path):
    try:
        sorgente = percorso.read_text(encoding="utf-8", errors="replace")
        albero = ast.parse(sorgente, filename=str(percorso))
    except SyntaxError as e:
        return [("ERRORE", e.lineno or 0, f"file non analizzabile: {e.msg}")]

    trovati = []
    for nodo in ast.walk(albero):
        if not isinstance(nodo, ast.Call):
            continue
        nome = nome_chiamata(nodo)
        ultimo = nome.split(".")[-1]
        primo_arg = nodo.args[0] if nodo.args else None

        if nome in ("os.system", "os.popen"):
            livello = "MEDIO" if primo_arg is not None and e_costante(primo_arg) else "ALTO"
            trovati.append((livello, nodo.lineno, f"{nome}(): passa sempre dalla shell"))
            continue

        if ultimo in FUNZIONI_SUBPROCESS and ("subprocess" in nome or nome == ultimo):
            if ultimo in ("getoutput", "getstatusoutput"):
                livello = "MEDIO" if primo_arg is not None and e_costante(primo_arg) else "ALTO"
                trovati.append((livello, nodo.lineno, f"{nome}(): usa sempre la shell"))
                continue
            for kw in nodo.keywords:
                if kw.arg != "shell":
                    continue
                if isinstance(kw.value, ast.Constant) and kw.value.value is False:
                    break
                if isinstance(kw.value, ast.Constant) and kw.value.value is True:
                    if primo_arg is not None and e_costante(primo_arg):
                        trovati.append(("MEDIO", nodo.lineno, f"{nome}(shell=True) con comando fisso"))
                    else:
                        trovati.append(("ALTO", nodo.lineno, f"{nome}(shell=True) con comando costruito a runtime"))
                else:
                    trovati.append(("MEDIO", nodo.lineno, f"{nome}(shell=<variabile>): verifica il valore"))
    return sorted(trovati, key=lambda t: t[1])


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


def scansiona(radice: Path, escluse: set) -> int:
    totale, analizzati = 0, 0
    conteggio = {"ALTO": 0, "MEDIO": 0, "ERRORE": 0}
    for f in elenca_file(radice, escluse):
        analizzati += 1
        for livello, riga, msg in analizza_file(f):
            rel = f.relative_to(radice) if radice.is_dir() else f.name
            print(f"[{livello}] {rel}:{riga}  {msg}")
            conteggio[livello] += 1
            totale += 1
    print(f"\nFile .py analizzati: {analizzati}")
    print(f"Segnalazioni: {totale} (ALTO {conteggio['ALTO']}, MEDIO {conteggio['MEDIO']}, ERRORE {conteggio['ERRORE']})")
    return 1 if totale else 0


def main() -> int:
    p = argparse.ArgumentParser(
        description="Cerca shell=True, os.system e os.popen nei file Python (e mostra una demo).")
    p.add_argument("percorso", nargs="?", default=".", help="cartella o file .py da analizzare (default: cartella corrente)")
    p.add_argument("--demo", action="store_true", help="esegue la dimostrazione vulnerabile/sicuro e termina")
    p.add_argument("--escludi", action="append", default=[], metavar="NOME",
                   help="nome di cartella da saltare (ripetibile); si aggiunge a .venv, venv, node_modules, .git...")
    p.add_argument("--version", action="version", version=f"%(prog)s {VERSIONE}")
    a = p.parse_args()

    if a.demo:
        return demo()
    radice = Path(a.percorso)
    if not radice.exists():
        print(f"Percorso inesistente: {radice}", file=sys.stderr)
        return 2
    return scansiona(radice, ESCLUSE_DEFAULT | set(a.escludi))


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