shell=True e subprocess: pattern sicuri in Python (Windows / macOS / Linux)
Eseguire comandi da Python senza rischiare la command injection: lista di argomenti, check, timeout, shlex e uno script che trova shell=True nel tuo codice.
Hai chiesto all’IA uno script che comprime la cartella di un cliente, converte una fattura in PDF o controlla se il server della PEC risponde. Funziona al primo colpo, e dentro c’è una riga come subprocess.run(f"tar -czf backup.tar.gz clienti/{cliente}", shell=True). Nella guida alle 7 falle di sicurezza nel codice scritto dall’IA la segnalo come falla numero 2; qui vedi come correggerla davvero. Con shell=True e un pezzo di testo che arriva da fuori, chi scrive quel testo può far eseguire al tuo computer un comando in più; con una lista di argomenti, lo stesso testo resta un semplice argomento.
La risposta breve: passa il comando come lista (["tar", "-czf", "backup.tar.gz", percorso]), lascia shell al valore predefinito False, aggiungi check=True e timeout, e risolvi il programma con shutil.which(). Usa la shell solo quando serve una sua funzione (pipe, comandi interni come dir) e senza input esterno dentro la stringa. In fondo trovi uno script da scaricare che fa la dimostrazione e cerca shell=True, os.system e os.popen nei tuoi file.
ℹ️ Nota / Prerequisito operativo: ti serve Python 3 installato e un terminale. Su Windows usa Prompt dei comandi o PowerShell e il comando
python; su macOS e Linux il Terminale e il comandopython3. Gli esempi usano solo la libreria standard: nientepip install.
Perché shell=True cambia tutto
Quando passi una stringa con shell=True, Python non avvia il programma che hai in mente: avvia l’interprete dei comandi del sistema e gli consegna la stringa intera. Su macOS e Linux è /bin/sh; su Windows è il programma indicato dalla variabile COMSPEC, di solito cmd.exe. Lo dice la documentazione ufficiale del modulo subprocess, che aggiunge: con la shell, mettere tra virgolette spazi e caratteri speciali è responsabilità di chi scrive il codice.
I caratteri che contano sono quelli che la shell interpreta: ;, &&, |, > e $(…) su macOS e Linux; &, |, > e ^ in cmd.exe. Se il nome di un cliente, un indirizzo IP digitato in un modulo o il nome di un file caricato contengono uno di questi caratteri, la shell li tratta come istruzioni.
Con una lista, invece, nessuna shell si mette in mezzo: il programma riceve ogni elemento come argomento separato, così com’è. Su Windows la lista viene convertita da Python in una riga di comando con il quoting necessario, perché la funzione di sistema CreateProcess accetta solo stringhe; cmd.exe, però, non entra in gioco (con un’eccezione per i file .bat, che vedi più sotto).
La differenza vista in pratica
Lo script controlla_subprocess.py (SHA-256 90773e4ea78c603b8122e3b6a4958ec94acbe7b1a764988735570819d294b83c) contiene una dimostrazione innocua. Simula un utente che, al posto del nome di una foto, scrive foto.jpg ; echo INIETTATO> iniettato.txt (su Windows il separatore è &). Il comando lanciato è l’interprete Python stesso, che stampa gli argomenti ricevuti; tutto avviene in una cartella temporanea che lo script cancella alla fine. Non apre connessioni di rete.
Salvalo nella cartella Download, poi verifica l’hash.
Windows (PowerShell):
cd $HOME\Downloads
Get-FileHash .\controlla_subprocess.py -Algorithm SHA256
python controlla_subprocess.py --demo
macOS / Linux (Terminale):
cd ~/Downloads
shasum -a 256 controlla_subprocess.py
python3 controlla_subprocess.py --demo
Get-FileHash e shasum -a 256 devono restituire esattamente l’hash scritto sopra (PowerShell lo mostra in maiuscolo: è lo stesso valore). Se è diverso, non eseguire il file e scaricalo di nuovo. Su alcune distribuzioni Linux al posto di shasum c’è sha256sum controlla_subprocess.py.
Output della demo su macOS 15.7.7 e Debian 13 (identico sui due sistemi):
Sistema: POSIX (/bin/sh)
Input simulato dell'utente: 'foto.jpg ; echo INIETTATO> iniettato.txt'
1) VULNERABILE: stringa + shell=True
output: argomento ricevuto: ['foto.jpg']
file iniettato.txt creato? SI
2) SICURO: lista di argomenti, shell=False (default)
output: argomento ricevuto: ['foto.jpg ; echo INIETTATO> iniettato.txt']
file iniettato.txt creato? no
3) SE LA SHELL SERVE DAVVERO (solo POSIX): shlex.quote()
output: argomento ricevuto: ['foto.jpg ; echo INIETTATO> iniettato.txt']
file iniettato.txt creato? no
Cartella temporanea eliminata.
Nel caso 1 il programma riceve solo foto.jpg: il resto della stringa l’ha eseguito la shell come comando separato, e il file iniettato.txt è comparso. Al posto di echo poteva esserci un comando che cancella file o li invia altrove, con i permessi del tuo utente. Nei casi 2 e 3 lo stesso testo arriva intero come un solo argomento e non succede nient’altro. Su Windows la demo salta il caso 3, perché shlex.quote() non è pensata per cmd.exe.
Il pattern sicuro: lista di argomenti, check e timeout
Questa funzione raccoglie le buone pratiche in un punto solo. Puoi copiarla nel tuo progetto e usarla al posto delle chiamate sparse a subprocess.run.
import shutil
import subprocess
import sys
def esegui(programma, *argomenti, timeout=60, cartella=None):
percorso = shutil.which(programma)
if percorso is None:
raise FileNotFoundError(f"{programma} non trovato nel PATH")
return subprocess.run(
[percorso, *argomenti],
capture_output=True,
text=True,
check=True,
timeout=timeout,
cwd=cartella,
)
if __name__ == "__main__":
r = esegui("git", "--version")
print("OK:", r.stdout.strip())
try:
esegui("git", "comando-che-non-esiste")
except subprocess.CalledProcessError as e:
print("Errore, codice", e.returncode, "->", e.stderr.strip().splitlines()[0])
try:
esegui(sys.executable, "-c", "import time; time.sleep(5)", timeout=1)
except subprocess.TimeoutExpired as e:
print("Timeout dopo", e.timeout, "secondi: processo terminato")
try:
esegui("programma-inesistente")
except FileNotFoundError as e:
print("Errore:", e)
Salvala come pattern_sicuro.py e lanciala con python pattern_sicuro.py (Windows) o python3 pattern_sicuro.py (macOS / Linux). Serve Git installato per le prime due prove. Output su macOS 15.7.7, con Git in italiano:
OK: git version 2.55.0
Errore, codice 1 -> git: 'comando-che-non-esiste' non è un comando git. Vedi 'git --help'.
Timeout dopo 1 secondi: processo terminato
Errore: programma-inesistente non trovato nel PATH
Su Debian 13 cambiano solo la versione di Git (2.47.3) e la lingua del messaggio d’errore, che lì è in inglese.
I parametri, uno per uno
| Parametro | Default | Cosa fa e quando usarlo |
|---|---|---|
| args come lista["programma", "arg1", …] | Consigliato | Ogni elemento è un argomento separato; spazi e simboli non vengono interpretati. È la base di tutto il resto. |
| shellTrue / False | False | Lascialo così. Con True la stringa passa da /bin/sh o cmd.exe. |
| checkTrue / False | False | Con True, un codice di uscita diverso da 0 solleva CalledProcessError invece di passare in silenzio. |
| timeoutsecondi | Nessuno | Allo scadere il processo figlio viene terminato e arriva TimeoutExpired. Evita script bloccati per sempre. |
| capture_output + textTrue | False | Raccoglie stdout e stderr come testo, così puoi leggerli o registrarli invece di vederli mescolati nel terminale. |
| cwdpercorso | Cartella corrente | Esegue il comando in una cartella precisa, senza cd dentro una stringa di shell. |
| envdizionario | Ereditato | Se lo passi, sostituisce tutte le variabili d’ambiente del processo figlio invece di ereditarle. |
| shutil.which()funzione a parte | Consigliato | Trova il percorso completo del programma nel PATH; se manca, lo sai prima di eseguire. |
Qualche dettaglio che la tabella non dice.
- Lista:
subprocess.run(["git", "log", "--oneline", nome_branch]). Anche senome_branchcontiene; rm, Git riceve quel testo come nome di un ramo e risponde che non esiste. check=True: senza,subprocess.run(["git", "push"])può fallire e il tuo script prosegue come se nulla fosse. Concheck=Truel’errore emerge e lo gestisci contry / except subprocess.CalledProcessError.timeout=60: la documentazione precisa che il figlio viene terminato e atteso prima che l’eccezione arrivi a te. Scegli un valore realistico: 300 secondi per un backup grande, 10 per un--version.capture_output=True, text=True:r.stdouter.stderrdiventano stringhe. Senzatext=Truesono byte.cwd=r"C:\Users\tuonome\clienti"su Windows ocwd="/Users/tuonome/clienti"su macOS (/home/tuonome/clientisu Linux). Su macOS e Linux, se il programma è indicato con un percorso relativo, viene cercato rispetto acwd.env: per aggiungere una variabile senza perdere le altre scrivienv={**os.environ, "LANG": "C"}. Un dizionario con una sola voce lascerebbe il figlio senzaPATH.shutil.which("git"): restituisce il percorso completo, per esempio/usr/bin/gitsu Debian, oppureNonese Git non è installato. La documentazione Python consiglia il percorso completo dell’eseguibile per la massima affidabilità; per rilanciare Python stesso usasys.executable.
Tre combinazioni tipiche: per un comando veloce di cui ti interessa l’output, lista + capture_output=True, text=True, check=True, timeout=30; per un’operazione lunga che deve fallire in modo visibile, lista + check=True, timeout=600, cwd=…; per un programma che deve girare con una variabile in più, lista + env={**os.environ, "NOME": "valore"}. L’aiuto completo è nella documentazione o, dal terminale, con python -c "import subprocess; help(subprocess.run)" (python3 su macOS e Linux).
Quando la shell serve davvero
Ci sono casi in cui shell=True ha senso. Su Windows, secondo la documentazione Python, serve solo per i comandi interni di cmd.exe come dir o copy; non serve per lanciare un file .bat o un normale programma da console. Su macOS e Linux serve per pipe (|), redirezioni e caratteri jolly. Nella maggior parte dei casi esiste un’alternativa in Python puro: shutil.copy() al posto di copy, os.listdir() o pathlib al posto di dir/ls, shutil.make_archive() al posto di tar o zip.
Se proprio devi usarla, la regola è una: nessun testo esterno dentro la stringa senza quoting. Su macOS e Linux puoi usare shlex.quote():
import shlex
import subprocess
nome = "fattura marzo; rm x.pdf"
subprocess.run(f"pdftotext {shlex.quote(nome)} - | head -n 5", shell=True, check=True, timeout=30)
shlex.quote(nome) produce 'fattura marzo; rm x.pdf', tra apici singoli: la shell lo vede come un solo nome di file. Il suo fratello shlex.split() fa il percorso inverso: trasforma una riga scritta come in un terminale in una lista sicura, per esempio shlex.split('pdftotext "fattura marzo.pdf" -') restituisce ['pdftotext', 'fattura marzo.pdf', '-']. È utile quando un comando fisso arriva da un file di configurazione; non rende sicuro un comando scritto da un utente.
⚠️ Attenzione —
shlexnon vale per Windows: la documentazione del modulo shlex avverte che è pensato per le shell Unix e che sucmd.exee sulle shell non POSIX il quoting non è garantito, con possibile command injection. Su Windows la strada sicura resta la lista senza shell.
Windows: due dettagli da conoscere
I file .bat e .cmd. La documentazione Python, nella sezione sulla sicurezza, avverte che Windows può avviare questi file attraverso la shell di sistema anche se tu passi una lista senza shell=True: gli argomenti possono allora essere interpretati con le regole della shell, senza l’escaping che aggiunge Python. Se lanci un .bat con argomenti che arrivano da fonti non fidate, la documentazione suggerisce di passare shell=True, così Python applica l’escaping dei caratteri speciali. Meglio ancora, se puoi, chiamare direttamente il programma che il .bat avvia. Il dettaglio è discusso nella issue gh-114539 di CPython.
Quale cmd.exe viene eseguito. Da Python 3.12, con shell=True su Windows l’interprete viene cercato tramite %COMSPEC% e %SystemRoot%\System32\cmd.exe invece che nella cartella corrente e nel %PATH%. Secondo la documentazione, questo impedisce di far eseguire un falso cmd.exe lasciato nella cartella corrente. Se usi una versione precedente, è un motivo in più per aggiornare. Controlla la tua con python --version.
Cerca shell=True nei tuoi script
La seconda funzione di controlla_subprocess.py è uno scanner. Legge i file .py senza eseguirli (usa il modulo ast, che analizza la struttura del codice) e segnala:
- ALTO:
shell=True,os.system()oos.popen()con un comando costruito mentre il programma gira (f-string, concatenazioni, variabili); - MEDIO: gli stessi con un comando fisso scritto nel codice, oppure
shell=con un valore non letterale da verificare.
Dalla cartella del tuo progetto:
Windows (Prompt dei comandi):
python %USERPROFILE%\Downloads\controlla_subprocess.py .
Windows (PowerShell):
python $HOME\Downloads\controlla_subprocess.py .
macOS / Linux:
python3 ~/Downloads/controlla_subprocess.py .
Le opzioni:
percorso(default.): la cartella o il singolo file.pyda analizzare.--escludi NOME: salta una cartella con quel nome; ripetibile (--escludi test --escludi vecchi)..venv,venv,env,node_modules,.git,__pycache__,site-packages,buildedistsono già esclusi.--demo: la dimostrazione vista sopra.--versione-h: versione e aiuto.
Prendi un mini progetto d’esempio con due file tipici di uno studio professionale: backup_clienti.py (comprime la cartella del cliente scelto nel gestionale) e strumenti/controlla_pec.py (fa un ping al server di posta e converte i PDF). Output dello scanner, uguale su macOS e Debian:
[ALTO] backup_clienti.py:6 subprocess.run(shell=True) con comando costruito a runtime
[MEDIO] backup_clienti.py:12 os.system(): passa sempre dalla shell
[ALTO] strumenti/controlla_pec.py:5 os.popen(): passa sempre dalla shell
[MEDIO] strumenti/controlla_pec.py:8 subprocess.check_output(shell=<variabile>): verifica il valore
[MEDIO] strumenti/controlla_pec.py:11 subprocess.check_output(shell=True) con comando fisso
File .py analizzati: 2
Segnalazioni: 5 (ALTO 2, MEDIO 3, ERRORE 0)
Un file con os.system(input()) nella cartella .venv del progetto non compare: è escluso di default. Lo script termina con codice 1 se trova qualcosa, 0 se non trova nulla, 2 se il percorso non esiste: puoi usarlo come controllo prima di un commit. Le righe ALTO si correggono per prime, con il pattern a lista; le MEDIO vanno lette una per una, perché un comando fisso senza input esterno non è di per sé una vulnerabilità.
💡 Consiglio pratico — Cosa non vede lo scanner: cerca solo questi schemi nei file
.py. Non segue le variabili da un file all’altro, non controllaeval,pickleo altre falle, e non vede unshell=Truenascosto in una libreria. Nessuna segnalazione significa «nessuno di questi schemi», non «codice sicuro».
Errori tipici
FileNotFoundError: [Errno 2] No such file or directory: 'git --version'su macOS o Linux → hai toltoshell=Truema hai lasciato la stringa intera → dividila in lista:["git", "--version"], o usashlex.split()se il comando è fisso.- Stesso errore con
["git --version"]→ la lista ha un solo elemento con lo spazio dentro → ogni argomento va in un elemento separato. - Su Windows
subprocess.run(["dir"])non trova il programma →dirnon è un eseguibile ma un comando interno dicmd.exe→ usaos.listdir()opathlib.Path().iterdir(); solo se serve davvero,shell=Truecon una stringa fissa. - Lo script resta fermo per sempre → il programma figlio aspetta un input o la rete → aggiungi
timeout=e gestiscisubprocess.TimeoutExpired. - Il comando fallisce ma lo script prosegue → manca
check=True→ aggiungilo, oppure controlla a manor.returncode. - Dopo aver passato
env=il figlio non trova più i programmi → il dizionario ha sostituito tutto l’ambiente,PATHcompreso → usa{**os.environ, "NUOVA": "valore"}.
Domande Frequenti (FAQ)
shell=True è sempre una vulnerabilità?
No. Diventa una vulnerabilità quando nella stringa finisce testo che non controlli tu: un campo di un modulo, un nome di file caricato, un parametro da riga di comando di un altro utente. Con un comando fisso scritto nel codice il rischio d’iniezione non c’è, ma resti legato alla shell del sistema e ai suoi comportamenti diversi tra Windows e macOS/Linux.
os.system() è deprecato?
Non risulta deprecato, ma la documentazione Python indica subprocess come modulo da preferire per avviare processi e ha una sezione dedicata a come sostituire os.system() e os.popen(). Entrambi passano sempre dalla shell e non offrono check, timeout o la cattura separata dell’output.
Basta togliere shell=True per essere al sicuro?
Toglie l’iniezione di comandi tramite shell, che è il rischio principale. Restano altri problemi: il programma chiamato può interpretare un argomento che inizia con - come un’opzione, e un input esterno può indicare un percorso che non volevi. Valida gli input (per esempio, accetta solo nomi di cliente da un elenco) e, dove il programma lo supporta, usa -- prima degli argomenti liberi.
Come faccio una pipe come ps aux | grep python senza shell?
Avvia il primo comando con subprocess.run([...], capture_output=True, text=True) e filtra l’output in Python, per esempio con un ciclo sulle righe di r.stdout. È più leggibile e funziona uguale su tutti i sistemi. La documentazione di subprocess mostra anche come collegare due Popen se ti serve una pipe vera.
Lo stesso codice funziona su Windows, macOS e Linux?
Il pattern a lista sì; cambiano i programmi disponibili. tar e git esistono su tutti e tre se installati, mentre ping ha opzioni diverse (-n su Windows, -c su macOS e Linux). Per questo shutil.which() è utile: ti dice subito se il programma c’è, invece di scoprirlo con un errore a metà lavoro.
Lo scanner può modificare i miei file?
No. Li apre in sola lettura e ne analizza la struttura con ast, senza eseguirli. L’unica parte che esegue comandi è --demo, che lancia l’interprete Python in una cartella temporanea e la cancella alla fine.
Dopo la correzione
Corretto l’ultimo shell=True, rilancia lo scanner: l’obiettivo è nessuna riga ALTO e solo MEDIO che hai letto e deciso di tenere. Se il codice ti arriva dall’IA, puoi chiederle già il pattern giusto con i prompt di sicurezza specifici invece di quelli generici, e provare gli script nuovi in un ambiente di prova isolato per il vibe coding prima di lanciarli sui dati veri. Per le altre sei falle da cercare nello stesso codice, torna alla guida madre sulle 7 falle di sicurezza nel codice scritto dall’IA.