Attacco alla supply chain su PyPI: la lezione di LiteLLM
Il compromesso di LiteLLM su PyPI ha distribuito un ladro di credenziali in ogni wheel. Ecco cosa avrebbero fermato davvero hash pinning e lockfile, e cosa no.

Tre file. Tutta l'impronta di uno degli attacchi alla supply chain Python più pericolosi che abbiamo visto: un cook.pth da 12 byte, un cook.py da 22 KB e un sitecustomize.py da 25 KB. Quando LiteLLM 1.107.0 è approdato su PyPI, questi tre file viaggiavano dentro ciascuno dei suoi sette wheel e, per come funziona il meccanismo di avvio di Python, venivano eseguiti in ogni processo Python su un host infetto: la tua app, il runner CI, pip stesso, persino il language server nel tuo editor. Non serviva fare import litellm. Non serviva eseguirlo. Bastava avviare un interprete Python.
Il team di LiteLLM ha confermato la compromissione e PyPI ha ritirato la release nel giro di poche ore, ma la finestra è stata abbastanza lunga. Le immagini Docker malevole, incluso il tag latest, sono rimaste disponibili per circa due ore e mezza. Se la tua CI ha fatto pull durante quella finestra, il ladro aveva già sottratto chiavi SSH, credenziali cloud, password dei browser, segreti CI/CD e token degli strumenti AI, li aveva cifrati e li aveva inviati a un dominio controllato dall'attaccante. Non si trattava di typosquatting né di un pacchetto con nome simile. Era il pacchetto vero, costruito dalla pipeline vera, firmato dal processo di rilascio vero. Ed è questo che dovrebbe cambiare il modo in cui pensi alla fiducia nelle dipendenze.
Come tre file hanno dirottato la pipeline di rilascio ufficiale
L'attaccante non ha violato PyPI e non ha infilato un commit nel repository pubblico: la sorgente upstream su GitHub è pulita. Secondo il report dell'incidente di LiteLLM, l'attaccante aveva in mano le credenziali della GitHub CLI di tre sviluppatori, apparentemente recuperate dai log di un deployment interno di LiteLLM esposto su internet. Con il token di un owner dell'organizzazione ha aggirato la branch protection e ha indirizzato il workflow di rilascio privato verso un repository con nome simile, Berri-AI/litellm invece di BerriAI/litellm. La credenziale di un secondo sviluppatore ha soddisfatto il gate di approvazione. La pipeline ufficiale ha quindi costruito, firmato e pubblicato gli artefatti dell'attaccante.
L'iniezione vera e propria era elegantemente minuscola. Una sola riga in più nel pyproject.toml della sdist:
[tool.maturin]
python-source = "."
data = "cook" # <-- injected, copies cook/ into the wheel
python-packages = ["litellm_cli"] # legitimate, present upstream
Quella direttiva data = "cook" dice a maturin di includere una directory cook/ nel percorso purelib del wheel, e pip la installa diligentemente in site-packages. La sdist non includeva la cartella, quindi chi dava un'occhiata veloce alla release vedeva una sola riga criptica e nient'altro. Il payload si nascondeva negli artefatti di build, non nel sorgente. Controllare la cronologia git, quella che molti di noi considerano la verità di riferimento, non avrebbe mostrato nulla.
Perché i file .pth e sitecustomize.py sono un sogno per i malware
Due funzionalità della sequenza di avvio di Python hanno reso questo attacco insolitamente potente e, onestamente, sono funzionalità a cui la maggior parte degli sviluppatori Python non ha mai pensato. Primo: ogni file .pth in site-packages viene elaborato all'avvio dell'interprete, e le righe che iniziano con import vengono eseguite. Il cook.pth da 12 byte conteneva esattamente una riga, import cook, ed era sufficiente per eseguire il ladro in ogni processo Python della macchina. Secondo: all'avvio Python importa automaticamente un modulo chiamato sitecustomize, se ne trova uno nel percorso. Gli attaccanti ne hanno inserito uno proprio, che avviava un daemon staccato: ogni ora contattava un file di testo ospitato su GitHub, scaricava il codice indicato dall'attaccante e lo eseguiva con exec().
Questo è un problema molto più grave di un normale pacchetto malevolo con un setup.py avvelenato o un import sbagliato in __init__.py. Quelli si attivano quando installi o importi il pacchetto. Questo si attivava quando lanciavi qualsiasi cosa in Python. Il tuo pytest in CI. Il pip list che hai digitato per capire cosa fosse successo. Il formatter black nel tuo IDE. Ogni singola invocazione riattivava la trappola.
Vale la pena soffermarsi su ciò che il ladro raccoglieva davvero, perché l'elenco sembra una mappa della vita moderna dello sviluppatore: credenziali di AWS, Azure e gcloud, kubeconfig, token della GitHub CLI, chiavi SSH e GPG, archivi delle password dei browser, cronologie della shell, segreti CI di GitLab runner e Jenkins, credenziali Terraform, variabili d'ambiente di altri processi lette tramite /proc/<pid>/environ, credenziali dei metadati dell'istanza AWS e, con un'ironia amara, token di Claude Code, Cursor, Copilot e una dozzina di altri strumenti di coding con AI, oltre alle chiavi dei provider LLM memorizzate nel database del proxy di LiteLLM. Un solo pip install e un attaccante si ritrova con praticamente tutta la tua identità di ingegnere.
Non serviva fare import litellm. Avviare un qualsiasi interprete Python sull'host era sufficiente per eseguire il ladro.
Hash pinning e lockfile: necessari, ma non sufficienti
Ogni checklist di sicurezza della supply chain inizia da lockfile e hash pinning, e giustamente. Se fissi gli hash con pip install --require-hashes o usi uno strumento come uv o Poetry con hash bloccati, un wheel che cambia in silenzio dopo che l'hai verificato viene rifiutato. È una protezione reale contro un'intera classe di attacchi: manomissione dei registry, avvelenamento dei mirror, sostituzione degli artefatti.
Ma siamo onesti su cosa fa qui: niente, almeno per chi ha aggiornato per primo. L'hash pinning garantisce che l'artefatto che installi domani sia byte per byte identico a quello installato oggi. In questo attacco, i wheel malevoli erano gli artefatti ufficiali: di prima parte, costruiti dalla pipeline, con hash coerenti. Nel momento in cui qualcuno ha eseguito il suo aggiornamento di routine, ha risolto la nuova versione e ha committato il lockfile aggiornato, gli hash malevoli sono finiti fissati nel repo con un check CI verde. Il pinning ti protegge dal cambiamento, ma non dice nulla sulle intenzioni.
Dove il pinning brilla davvero in una storia come questa è nel raggio d'azione dei danni. I team con hash fissati e cadenze di aggiornamento deliberate non finiscono automaticamente su ogni release nell'ora in cui esce. Se la tua policy è «aggiornare le dipendenze in una PR settimanale, con un periodo di attesa di qualche giorno per tutto ciò che non è una correzione critica», la rimozione da PyPI alle 13:23 UTC è avvenuta prima ancora che si aprisse la tua finestra di aggiornamento. Un processo noioso batte la tecnologia furba più spesso di quanto vorremmo ammettere.
Quindi tieni i lockfile. Generali con gli hash. Ma smetti di raccontarti che sono una difesa contro una pipeline di manutentori compromessa: sono una difesa contro il drift e la manomissione, e un dosso di rallentamento contro le release sbagliate. Tutto qui.

L'attestazione di provenienza avrebbe individuato questo caso
Ecco il lato scomodo: questo attacco specifico aveva un'anomalia evidente e rilevabile, e gli strumenti dell'ecosistema per individuarla esistono già. I wheel malevoli non erano stati costruiti dal repository pubblico. Erano stati costruiti da un repo con nome simile creato dall'attaccante. Qualsiasi passaggio di verifica che chieda «questo artefatto è stato costruito dal commit e dal repository che dichiara?» sarebbe fallito in modo clamoroso.
È esattamente ciò che offre l'attestazione di provenienza. PyPI supporta le attestazioni PEP 740: quando un pacchetto viene pubblicato tramite Trusted Publishers da GitHub Actions, il workflow può generare una dichiarazione firmata che lega l'artefatto al repository, al workflow e al commit esatti che l'hanno prodotto. A quel punto un controllo lato consumatore diventa meccanico: verifichi l'attestazione, confermi che il repo sorgente sia BerriAI/litellm e non Berri-AI/litellm. I bundle Sigstore, la provenienza SLSA e le verifiche in stile pip-audit appartengono tutti a questa famiglia.
Il problema oggi sono adozione e applicazione. Le attestazioni sono opzionali per chi pubblica, e quasi nessuno le verifica al momento dell'installazione anche quando esistono. È questa la parte che deve cambiare, e richiama un punto che abbiamo già toccato su l'infrastruttura di fiducia che funziona solo quando qualcuno la controlla davvero: una firma che nessuno verifica è pura decorazione. Se la tua organizzazione gestisce un proprio indice di pacchetti o un proxy, e se hai più di qualche manciata di ingegneri dovresti, «rifiuta gli artefatti senza una provenienza verificabile dalla sorgente attesa» è una policy che puoi applicare centralmente, una volta, per ogni sviluppatore.
C'è anche una lezione più sottile. L'attaccante aveva bisogno di tre credenziali di sviluppatori: una per reindirizzare il workflow, una per approvarlo, una per non farsi notare. È un fallimento sistemico nell'igiene delle credenziali, non un errore isolato. I token Trusted Publisher su PyPI basati su OIDC e di breve durata avrebbero ridotto drasticamente la superficie d'attacco, perché non c'è alcun token a lunga durata che stia in un file di log pronto per essere rubato. Se il tuo progetto pubblica ancora con token API salvati come segreti CI, consideralo un promemoria.
Installazioni in sandbox e il caso dei periodi di attesa
Assumiamo lo scenario peggiore: una release malevola sfugge alla tua igiene dei lockfile e ai controlli di provenienza, perché prima o poi succederà. Cosa resta? Il contenimento. La domanda smette di essere «come preveniamo i pacchetti malevoli» e diventa «quando un pacchetto malevolo viene eseguito, a cosa può arrivare?»
Per la CI la risposta è davvero raggiungibile. Le build dovrebbero girare in ambienti effimeri, senza credenziali a lunga durata, con token limitati e filtraggio dell'egress. Il comportamento del ladro di LiteLLM è rumoroso sulla rete, se sei in ascolto: una POST verso un dominio sconosciuto con un header di autenticazione personalizzato, un fetch da un URL raw di GitHub in loop orario e, il segnale più evidente di tutti, un processo Python che interroga 169.254.169.254, il servizio dei metadati dell'istanza AWS. Il codice applicativo quasi mai ha un motivo legittimo per colpire direttamente l'IMDS; ci pensa l'SDK. Se generi un alert ogni volta che un processo Python tocca quell'endpoint, intercetterai un'intera famiglia di ladri di credenziali, non solo questo. Abbiamo già scritto di i punti ciechi nel logging dell'autenticazione cloud: questo è uno di quelli, ed è economico da chiudere.
Le macchine degli sviluppatori sono più difficili, e non fingerò il contrario. Un laptop dove lo stesso utente usa editor, browser, agent SSH e un numero imprecisato di pip install è un buffet per i ladri. Le mosse pragmatiche: fai le installazioni esplorative in container o devcontainer invece che nell'ambiente dell'host; tieni le installazioni dei pacchetti dentro il devcontainer del progetto, così un sitecustomize.py malevolo non finisce mai nei site-packages dell'host; e considera ~/.aws/credentials una responsabilità. SSO con token a breve durata (aws sso login, gcloud auth) significa molto meno materiale su disco da rubare. Niente di tutto questo è esotico. È solo attrito, e l'attrito è proprio il punto.
- Fissa versioni e hash in un lockfile, ma tratta gli aggiornamenti come eventi deliberati, non come rumore di fondo.
- Imposta una finestra di attesa, da 48 a 72 ore, prima che gli aggiornamenti delle dipendenze non critiche arrivino alle build di produzione. La maggior parte delle release malevole viene ritirata entro poche ore.
- Verifica le attestazioni di provenienza dove esistono, e preferisci i pacchetti pubblicati tramite Trusted Publishers con OIDC rispetto ai token API a lunga durata.
- Esegui le build CI in ambienti effimeri senza credenziali, con filtraggio dell'egress; genera alert su ogni processo Python che tocca gli endpoint dei metadati dell'istanza.
- Installa ed esegui codice di terze parti in container sulle macchine degli sviluppatori, e sostituisci i file di credenziali cloud statici con sessioni SSO a breve durata.
- Verifica nel tuo ambiente la presenza degli IoC se hai scaricato qualcosa nella finestra:
cook.pth,cook.pyo unsitecustomize.pysospetto in site-packages, e/tmp/.pc_once_*o/tmp/remote_code_loader.locksu disco.
Cosa dice questo attacco su dove si annida davvero il rischio
Nota cosa l'attaccante non si è preso la briga di fare. Non ha sfruttato una vulnerabilità nel codice. Non ha avuto bisogno di uno zero-day. Ha puntato al bersaglio più morbido del software moderno: le persone e le macchine che detengono credenziali permanenti. Tre token della GitHub CLI, apparentemente recuperati dai log di un deployment interno esposto su internet, sono bastati per prendere il controllo di una pipeline di rilascio che serve milioni di download. È lo stesso schema che abbiamo visto ripetersi nel settore: segreti sparsi in log, agent e file di configurazione, ciascuno una chiave universale. Se gestisci un'istanza interna di qualsiasi cosa e la esponi a internet, i suoi log fanno ormai parte del tuo modello delle minacce. Questa frase dovrebbe mettere a disagio qualcuno.
C'è anche un problema di velocità da nominare. LiteLLM è un progetto che si muove rapidamente, con decine di migliaia di commit e release frequenti, e sta al centro dello stack di strumenti AI, trascinato da framework e SDK che molti team non sanno nemmeno dipendere da lui. La portata transitiva significa che non puoi scegliere la tua esposizione scegliendo le dipendenze dirette. In questo scenario un SBOM smette di essere burocrazia di conformità: è il modo di rispondere a «siamo coinvolti?» in minuti invece che in giorni. Gli strumenti che generano una distinta base dai dati reali di build, invece che dallo scraping dei manifest, individueranno le dipendenze che il tuo requirements.txt non menziona.

La mia opinione: la risposta del settore agli attacchi alla supply chain è stata troppo sbilanciata sulla teatralità della prevenzione, con scanner che segnalano versioni note come malevole a cose fatte, e troppo poco sui controlli poco appariscenti che avrebbero cambiato questa storia. Credenziali di pubblicazione a breve durata. Verifica della provenienza sul proxy. Periodi di attesa. Alert sull'egress verso gli endpoint dei metadati. Installazioni in sandbox. Nessuno di questi è un problema di ricerca. Sono decisioni di configurazione, disponibili oggi, e la maggior parte dei team non ne ha implementata nessuna. Gli attaccanti di LiteLLM avevano bisogno di una riga sbagliata in una config di build e di tre token rubati. Le difese elencate sopra li avrebbero costretti a superare quattro livelli indipendenti invece di uno. È questo che significa difesa in profondità: non più scanner, ma più modi indipendenti per fallire. È la stessa filosofia dietro sistemi ingegnerizzati per resistere alle catastrofi, invece di sistemi che sperano semplicemente nel meglio.
La prossima release compromessa è già in fase di pianificazione da qualcuno. Non potrai impedire che venga pubblicata. Ma puoi decidere se girerà con le tue chiavi AWS a portata di mano, il tuo agent SSH sbloccato e un percorso di rete chiaro verso un dominio controllato dall'attaccante, oppure se morirà in un container effimero senza credenziali e con un firewall. Quella decisione si prende adesso, nella configurazione della CI e nel setup del devcontainer, non il giorno in cui esce il report dell'incidente.


