Supply chain security di applicazioni AI: pinning dei modelli, audit di Langchain e LlamaIndex, integrity checks
Il 18 febbraio 2026, nella mia sandbox di audit AI, ho simulato un supply chain attack su un'infrastruttura di retrieval RAG standard. L'ambiente era un Hetzner CX32 (4 vCPU Intel, 8 GB RAM DDR4, 80 GB NVMe) con Debian 12, Python 3.12, Langchain 0.3.18, LlamaIndex 0.11, un vector store pgvector su PostgreSQL 16, un modello di embedding Nomic v1.5 scaricato da Hugging Face tramite tag main, e Claude Sonnet 4.6 come generatore tramite API. L'attacco non era sofisticato - ho semplicemente sostituito in un repository privato una dipendenza transitiva di Langchain (un pacchetto non-molto-visto ma effettivamente presente nella catena di dipendenze) con una versione che, al primo import, inviava le prime 20 chiamate di embedding a un endpoint di raccolta esterno. Non ho toccato il codice Langchain, non ho toccato il modello, non ho toccato l'applicazione a valle. Ho toccato un pacchetto a distanza di due salti nel grafo delle dipendenze. Tre ore dopo il deploy, il mio collector aveva raccolto 340 chunk di embedding corrispondenti a query utenti del sistema - sufficienti a ricostruire buona parte del vocabolario interno dell'organizzazione. Se questo fosse stato un attacco reale su un'applicazione RAG in produzione di una PMI italiana, il danno sarebbe stato invisibile per settimane. La lezione operativa dell'esperimento è stata netta: nel 2026 non puoi più pensare alla supply chain security di una pipeline AI come "installa da PyPI e fidati". Ogni anello della catena - modelli, librerie AI, tool di orchestrazione, immagini Docker di runtime - è una superficie di attacco attiva e in crescita, classificata da OWASP come LLM03 Supply Chain Vulnerabilities tra le prime tre minacce per applicazioni LLM 2025-2026.
Perché il pinning per tag di un modello AI è una falla di supply chain?
La risposta operativa è che un tag Hugging Face - main, latest, persino un nome di versione semantica come v1.5 - è un puntatore mutabile: oggi punta al commit X, domani può puntare al commit Y perché il maintainer ha fatto un nuovo push. Il tuo deploy che dichiara model="nomic-ai/nomic-embed-text-v1.5" scarica oggi un blob di 274 MB e domani, a un docker build successivo, ne scarica un altro - senza che niente nel tuo codice cambi, senza che un changelog ti avvisi, senza che tu possa dimostrare a posteriori quale versione hai usato. È esattamente il problema che il mondo container ha risolto dieci anni fa con digest pinning sui tag Docker (image@sha256:abc...), che nella pipeline AI viene ignorato con la stessa disinvoltura con cui il mondo Node.js ignorò per anni il problema fino all'incident event-stream del 2018.
Il pinning corretto su Hugging Face passa dal parametro revision - che può essere un commit hash completo, non un branch o tag. Il model hub versiona tutti i file via Git e ogni commit ha uno SHA-256 deterministico; scaricare un modello con revision uguale al commit hash significa ottenere esattamente gli stessi byte oggi, domani, e fra sei mesi. Se il maintainer pubblica una versione compromessa o semplicemente modificata, il tuo sistema non la scarica mai finché tu non cambi esplicitamente il revision - e quando lo fai, lo fai consapevolmente, con un diff in git che il tuo team di sicurezza vede.
from sentence_transformers import SentenceTransformer
# NON FARE QUESTO - tag mutabile
model = SentenceTransformer("nomic-ai/nomic-embed-text-v1.5")
# FAI QUESTO - revision a commit hash
model = SentenceTransformer(
"nomic-ai/nomic-embed-text-v1.5",
revision="a9d9ec1c4f18ab1d47d58c7c95e5e7dbd4fb8c3e",
)La differenza sembra accademica finché non devi rispondere a un audit che ti chiede "quale versione del modello processava i dati il 15 febbraio?" e tu non puoi dimostrarlo. Se vuoi vedere come affronto l'hardening di applicazioni AI dal punto di vista offensive - dove il pensiero d'attacco precede la difesa - nel mio hub sulla security AI per aziende trovi articoli operativi che partono dal threat model reale e arrivano a controlli applicativi concreti.
Le transitive dependency di Langchain: la superficie nascosta che non guardi mai
Un pip install langchain su un ambiente pulito tira giù, al marzo 2026, 78 pacchetti Python totali. Di questi, 10 sono librerie Langchain core. Gli altri 68 sono transitive dependencies - pacchetti di cui Langchain dipende, pacchetti di cui quei pacchetti dipendono, e così via per 4-5 livelli di profondità. La probabilità che tu abbia mai letto il codice di uno solo di quei 68 pacchetti è praticamente zero. La probabilità che un attacker abbia individuato uno dei 68 meno sorvegliati come vettore di supply chain attack è, invece, reale.
Il pattern di rischio è quello del typosquatting e del maintainer takeover. Nel typosquatting, un attaccante pubblica un pacchetto con nome simile al legittimo (requesfs invece di requests) sperando che qualcuno sbagli a digitare. Nel maintainer takeover, un manutentore reale di un pacchetto minore cede o vende l'account - PyPI non ha un processo formale di verifica sulla continuità del maintainer. Il nuovo maintainer rilascia una patch point release (0.3.1 → 0.3.2) apparentemente innocua che però aggiunge codice malevolo, e la patch fluisce automaticamente nei sistemi che usano pinning lasco (>=0.3.1).
La disciplina che applico ha quattro pezzi. Primo: pip install --require-hashes con un requirements.txt dove ogni dipendenza ha il suo hash SHA-256 calcolato. Secondo: dependency scan automatico via pip-audit (tool ufficiale PyPA) e Dependabot in CI. Terzo: lock file strict (poetry.lock o uv.lock) checkato in git, non rigenerato al deploy. Quarto: private mirror dei pacchetti che uso (Nexus, JFrog, o anche Harbor con proxy PyPI) - i worker di produzione non scaricano mai direttamente da PyPI pubblico, scaricano da un mirror che ho la possibilità di audit-are.
# requirements.txt con hash esplicito (generato con pip-compile)
langchain==0.3.18 \
--hash=sha256:d1e8b8f5c7e3a... \
--hash=sha256:7f3b2a1c4e5d6...
pydantic==2.9.2 \
--hash=sha256:f048cec7b26fd...Il costo di questa disciplina è 20-30 minuti iniziali di setup e 5 minuti per ogni aggiornamento di dipendenza. Il beneficio è che un attacco di tipo event-stream (injection post-compromise del maintainer) non ti colpisce finché tu stesso non approvi il nuovo hash - a quel punto l'attacco è un opt-in, non un opt-out.
Il drift di comportamento da update automatici: quando aggiornare diventa un incident
Supponi di fare tutto bene - hash pinning, mirror privato, lock file strict. Ogni lunedì mattina, per diligenza, rilasci una patch di versione minor a tutte le dipendenze, test automatici passano, deploy in produzione. Lunedì 3 febbraio 2026, dopo aver rilasciato una patch di minor su Langchain, noti che le risposte del chatbot aziendale sono leggermente diverse - le stesse domande producono wording diverso. Investigando scopri che Langchain, nel corso del minor bump, ha cambiato il default di un parametro di prompt composition (da include_system_context: true a false per compatibilità con un nuovo runtime). Il comportamento visibile del tuo sistema è cambiato, ma non c'è changelog entry esplicita e nessun warning in fase di upgrade.
Questo è behavioral drift from dependency update - un fenomeno che nell'ecosistema AI è molto più comune che nel software classico perché le librerie AI sono in rapidissima evoluzione, i maintainer cambiano default senza scrupolo, e i unit test raramente coprono semantica comportamentale del modello. La mitigazione che applico non è tecnica ma procedurale: ogni update di dipendenza AI (Langchain, LlamaIndex, sentence-transformers, Anthropic SDK, OpenAI SDK) passa da un test set di end-to-end golden queries con output atteso. Se più del 5% delle query golden produce output semanticamente diverso dalla baseline (calcolato con BLEU, rougeL o embedding similarity), l'update va in review manuale prima di entrare in produzione.
Il pattern è concettualmente simile al freshness loop che ho descritto nell'articolo sulla pipeline di documentazione tecnica con parser AST e linter: la differenza è che lì tracci il drift del contenuto generato, qui tracci il drift del comportamento sotto dipendenze esterne. In entrambi i casi, l'hash deterministico è il punto d'ancoraggio che rende misurabile il cambiamento.
Network egress control: il modello non parla a Internet tranne che con una allowlist
Un modello Python che gira dentro il tuo container con accesso di rete libero è una beacon potenziale. Se una dipendenza transitiva ha un call home malevolo, se un file di configurazione remoto risponde con JSON che fa partire un download, se una telemetry legittima è stata compromessa - tutto questo diventa traffico in uscita che il tuo sistema non ti chiede il permesso di generare. La difesa strutturale è network egress control: il container del runtime AI può uscire SOLO verso una lista esplicita di endpoint, tutto il resto è droppato dal firewall o dal sidecar di service mesh.
La configurazione minima che uso su un Docker Compose di sviluppo è una rete bridge con un egress proxy (Squid con whitelist o un piccolo caddy come forward proxy filtrante) davanti al container AI.
services:
ai-runtime:
image: myorg/ai-runtime:python3.12
environment:
HTTPS_PROXY: http://egress-proxy:3128
HTTP_PROXY: http://egress-proxy:3128
NO_PROXY: localhost,127.0.0.1,postgres
networks:
- ai-internal
egress-proxy:
image: ubuntu/squid:6.0-24.04_stable
volumes:
- ./squid-allowlist.conf:/etc/squid/squid.conf:ro
networks:
- ai-internal
networks:
ai-internal:
driver: bridgeE il file squid-allowlist.conf contiene il dominio esplicito:
acl whitelist dstdomain .anthropic.com
acl whitelist dstdomain .huggingface.co
acl whitelist dstdomain cdn-lfs.huggingface.co
http_access allow whitelist
http_access deny allQualunque tentativo di uscire verso un dominio non nella lista - anche un'innocua chiamata a pypi.org da parte di una dipendenza poco disciplinata - viene droppato con 403 e lasciato nel log Squid. Questo fail-closed by default è il principio che applico in parallelo alla disciplina di least privilege sui tool che ho descritto per agent systems: un componente che dichiara what non deve fare (network egress libero, tool non necessari) è un componente di cui posso verificare il comportamento. Un componente che ha tutto per default è un componente di cui non posso dire nulla prima che l'incident accada.
Integrity check a ogni deploy: la riproducibilità come difesa
Il deploy dell'applicazione AI non è completo finché non ho verificato che tutto ciò che gira corrisponde esattamente a ciò che mi aspettavo. La pipeline che uso produce e confronta tre hash diversi al momento del deploy. Il primo: hash dell'immagine Docker (docker image inspect --format '{{.Id}}'). Il secondo: hash del blob del modello (letto dal filesystem del container, verificato contro il valore atteso in una environment variable passata al runtime). Il terzo: hash aggregato delle dipendenze Python (pip list --format=freeze | sha256sum). Se uno dei tre non matcha il valore atteso, il deploy fallisce con errore esplicito e l'immagine precedente resta in servizio.
#!/bin/bash
set -euo pipefail
EXPECTED_IMAGE_DIGEST="sha256:a7b8c9d0..."
EXPECTED_MODEL_DIGEST="sha256:e1f2a3b4..."
EXPECTED_PIP_DIGEST="sha256:5a6b7c8d..."
ACTUAL_IMAGE=$(docker image inspect --format '{{.Id}}' myorg/ai-runtime:prod)
ACTUAL_MODEL=$(docker run --rm myorg/ai-runtime:prod sha256sum /opt/models/embed/model.safetensors | awk '{print "sha256:"$1}')
ACTUAL_PIP=$(docker run --rm myorg/ai-runtime:prod sh -c "pip list --format=freeze | sha256sum" | awk '{print "sha256:"$1}')
[ "$ACTUAL_IMAGE" = "$EXPECTED_IMAGE_DIGEST" ] || { echo "image digest mismatch"; exit 1; }
[ "$ACTUAL_MODEL" = "$EXPECTED_MODEL_DIGEST" ] || { echo "model digest mismatch"; exit 1; }
[ "$ACTUAL_PIP" = "$EXPECTED_PIP_DIGEST" ] || { echo "pip digest mismatch"; exit 1; }
echo "All integrity checks passed."Il costo di questo script è negligibile - 3-5 secondi al deploy. Il beneficio è che, se un compromesso qualsiasi ha modificato uno dei tre layer - immagine ricostruita con qualcosa di diverso, modello sostituito sul disk per errore, pacchetto aggiunto manualmente dal SRE sotto stress - il deploy si ferma. Nel contesto safetensors (formato Hugging Face raccomandato per i weights) il controllo hash è una proprietà nativa del formato: safetensors include metadata SHA-256 dei tensor che permette verifica incrementale. Il formato è documentato sul repository ufficiale Hugging Face safetensors ed è oggi il default per quasi tutti i modelli pubblicati sul hub. Il formato pickle (il vecchio default PyTorch) è invece esecuzione arbitraria di codice disguised come caricamento di tensor - da evitare categoricamente in produzione.
SBOM per stack AI: rendere auditabile ciò che hai
L'ultimo pilastro del mio workflow di supply chain è il Software Bill of Materials - SBOM. Per ogni release della mia pipeline AI genero un file SBOM in formato CycloneDX o SPDX che elenca, con hash, ogni componente: immagine Docker base, layer aggiunti, pacchetti Python con versioni e hash, modelli con repository e commit hash, prompt template serializzati. Uso syft (open source, Anchore) per generare l'SBOM dall'immagine Docker e un piccolo script che aggiunge i modelli e i template come entry supplementari.
La ragione strategica di avere l'SBOM non è la compliance normativa (NIS2 in Italia la richiede per operatori essenziali, ma è comunque una conseguenza, non una motivazione). La ragione operativa è che, quando domani PyPI notifica che il pacchetto example-ai-util versione 0.3.2 è stato compromesso, io voglio sapere in 10 secondi se e dove lo sto usando. Senza SBOM, la risposta è "scansioniamo i container di produzione e vediamo" - e richiede ore. Con SBOM, è un grep in un file che il team di sicurezza consulta quotidianamente.
Lo stesso ragionamento si applica ai modelli. Se domani un report di sicurezza rivela che nomic-embed-text-v1.5@a9d9ec1 aveva un backdoor impiantato prima di essere pubblicato (non è successo, è un esempio ipotetico), il mio SBOM mi dice immediatamente se ho quel commit hash installato da qualche parte. Con il pinning generico v1.5 non lo saprei mai con certezza.
Quando questa disciplina è sproporzionata
Se stai facendo un prototipo di 2 settimane in un'applicazione interna con 5 utenti, tutto questo è over-engineering: pip install con >= va bene e non cambierà la tua vita. Se lavori con modelli e librerie in un contesto puramente di ricerca - pubblicazioni accademiche, esperimenti, tesi - la supply chain conta meno perché il perimetro di attacco è limitato. Se il tuo stack AI è tutto gestito via API (ChatGPT, Claude, Gemini direttamente) senza hosting locale di modelli né di librerie di orchestrazione, la superficie di supply chain è ridotta al solo SDK ufficiale del vendor - comunque va pinnato, ma la complessità è un ordine di grandezza minore.
Il pattern pin-by-digest + mirror privato + egress control + integrity check + SBOM si giustifica quando hai contemporaneamente: applicazione AI in produzione (non prototipo) con utenti reali, dati aziendali che entrano nella pipeline di embedding o nel contesto LLM, obbligo di compliance (NIS2, GDPR, ISO 27001) o contratti con clienti che richiedono auditable AI, e superficie di attacco esterna visibile (il servizio è esposto su Internet o a partner/clienti). In quel punto l'investimento ripaga al primo incident reale che eviti - e di incident supply chain su ecosistema AI ne vedremo ancora molti nel 2026.
La differenza fra un sistema AI che può dire "abbiamo subito un attacco di supply chain il giorno X sul pacchetto Y, è stato rilevato in Z minuti, non è uscito un byte di dati" e uno che può dire solo "qualcosa è andato storto, non sappiamo bene cosa" è interamente in cinque pratiche - pinning, mirror, egress control, integrity check, SBOM - che nessuna è tecnicamente difficile da implementare ma che, insieme, trasformano una superficie opaca in un sistema auditabile. Il fatto che molte pipeline AI in produzione oggi non abbiano neanche una di queste cinque non è una prova che siano inutili: è una prova che l'AI slop dell'hardening è diventato accettabile tanto quanto l'AI slop del contenuto generato. Ripristinare la disciplina di ingegneria su un layer di tecnologia che evolve a velocità superiore è il lavoro dei prossimi due anni per chi fa seriamente consulenza di security su AI in aziende.
Se stai mettendo in produzione una pipeline AI - RAG, chatbot, agent, batch inference - e vuoi un audit della tua supply chain dal modello alla dipendenza Python, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.