GNN model training and serving microservice (and standalone), specialized in one-class anomaly detection for ZTA intrusion detection/prevention systems.
Graphagate analizza un flusso di accessi Zero-Trust continuo e in tempo reale utilizzando una Temporal Graph Network (TGN). Ogni accesso (una richiesta IP/device → risorsa) rappresenta un arco temporale (edge) contenente segnali Zero-Trust; il modello mantiene una memoria ricorrente del comportamento di ciascuna entità e una cronologia limitata dei suoi vicini temporali recenti, assegnando uno score a ogni nuovo evento in modo sequenziale.
- One-class anomaly detection — addestrato esclusivamente su traffico benigno tramite negative
sampling. Precisazione: il metodo è one-class / semi-supervised, non unsupervised: le label
selezionano il training set (solo benigno) e la soglia operativa primaria è calibrata con label
di lateral movement sullo slice di validazione. Nessuna label è mai usata per calcolare uno score
di test. Vedi il docstring di
src/train_tgn.py. Per ogni evento benigno il modello viene spinto verso benigno e su tre tipi di perturbazione verso anomalo; lo score di anomalia è calcolato come1 − P(benign). - Classi di anomalie rilevate — contestuale (fiducia TLS compromessa / allarmi dai sensori), policy (un'entità che agisce al di fuori del proprio ruolo/clearance/tier), movimento laterale (un accesso autorizzato ma non abituale — stesse feature d'arco del traffico benigno, rilevabile esclusivamente tramite lo storico delle interazioni) e credential theft (IP e device mai visti che si agganciano a un utente noto).
- Serving in tempo reale —
src/serve_tgn.pyespone le primitive (load_model,infer_score,update_memory,score_event,commit_event) esrc/serve_api.pyle incapsula in un microservizio di inferenza REST/JSON (graphagate.serve_api, profilo Composeserve-tgn) interrogabile da un orchestrator ZTA tramite HTTP. Il calcolo dello score avviene evento per evento; la memoria e lo storico del vicinato vengono aggiornati solo per gli eventi classificati come benigni (anti-poisoning gate), e unNodeRegistryammette dinamicamente entità mai viste prima a runtime (spazio dei nodi dinamico). Gli artifact per il deploy sonopublic/tgn_checkpoint.pt(pesi + memoria + raw-message store + buffer del vicinato) epublic/tgn_stats.json(soglia calibrata + registro). Verifica disponibile conpython -m graphagate.verify_tgn. Dettagli sull'integrazione (endpoint, flusso anti-poisoning OPA) indocs/orchestrator_integration.md.
Il modello (src/model/tgn.py, classe ZTATemporalGraphNetwork) combina la memoria
ricorrente del TGN canonico (Rossi et al., 2020) con un neighbour loader bounded in RAM
(nessun graph DB) e uno scorer a due teste — una basata sulle feature e una di
compatibilità strutturale — che insieme coprono le tre classi di anomalia.
flowchart TD
EV["Access event — schema v4, catena a 5 archi (chiavi namespaced per tipo)<br/>key_source(src:ip) → key_config(conf:ja3 ∨ conf:guest) → key_device(tpm: ∨ dev:guest*) → key_user → key_dst<br/>(+ binding config→user) · *dev:guest = collasso non-TPM, default; ck:/ipdev: se keying per-cookie<br/>(t, edge_msg sull'arco di accesso) + static attrs: role / clearance / tier / resource-risk / source-internal"]
REG["NodeRegistry<br/>entity key → memory slot<br/>dynamic admission · LRU eviction"]
NL["MessageNeighborLoader<br/>bounded ring-buffer [num_nodes, K]<br/>last K temporal neighbours (+ t, + msg)"]
EV --> REG --> NL
subgraph EMB["embed() — identity- & history-aware node embeddings"]
MEM["TGNMemory<br/>recurrent per-node state (GRU)<br/>z_mem + last_update (memory_dim=256)"]
ID["Hashed Identity<br/>stable_hash(key) % buckets → nn.Embedding [hash_buckets, hash_dim]"]
STAT["Static Node Features<br/>tier / risk / internal [16]"]
CAT["concat → x = [ z_mem ‖ node_feat ‖ id ]"]
GNN["GraphAttentionEmbedding<br/>TransformerConv (4 heads, num_hops=3, + residual)<br/>edge_attr = [ time_enc(Δt) ‖ hist_msg ]"]
MEM --> CAT
ID --> CAT
STAT --> CAT
CAT --> GNN
end
NL -->|"n_id, edge_index, hist_t, hist_msg"| MEM
NL --> GNN
subgraph SCOREG["score() — logit = feature head + structural head"]
FEAT["Feature head · LinkPredictor (MLP)<br/>[ z_src ‖ z_dst ‖ cur_msg ‖ feat_src ‖ feat_dst ‖ Δt ‖ Δt_src ‖ hist_feats ]<br/>→ policy & contextual anomalies"]
STR["Structural head<br/>scale · cosine( MLP z_src , MLP z_dst )<br/>→ lateral movement"]
SUM["logit = feat_logit + struct_logit"]
FEAT --> SUM
STR --> SUM
end
GNN -->|"z — per-node embedding"| FEAT
GNN --> STR
SUM --> SCO["anomaly score = 1 − σ(logit)"]
SCO --> PREC["score = min(1, score × precursor_boost)"]
PREC --> DEC{"score ≥ threshold ?<br/>(cost-sensitive routing)"}
DEC -->|anomaly| REP["report anomaly<br/>(+ arm precursor, drop trust)"]
DEC -->|benign| OK["allow"]
PREC --> GATE{"commit gate"}
GATE -->|"OPA ALLOW<br/>(measured protocol)"| UPD["update TGNMemory<br/>+ neighbor_loader.insert"]
GATE -->|"OPA DENY"| NOUPD["memory NOT updated"]
UPD -.->|writes back history| NL
Il gate misurato è quello di OPA, non lo score del modello. La decisione (
score ≥ threshold) e il commit gate sono due cose distinte. Nei numeri riportati il commit avviene sugli eventi che OPA ammetterebbe (proxy:not signal_dirty), non su quelli che il modello ritiene benigni — un modello che si auto-decide cosa memorizzare fa correre via l'FPR (vedi il docstring ditrain_tgn._replay). Il percorso auto-deciso esiste (score_event(update=True), endpoint/score) ed è la modalità OPA-less, ma non è il protocollo con cui sono state prodotte le misure.
Componente (attributo) |
Ruolo |
|---|---|
TGNMemory (memory) |
Stato ricorrente per-nodo aggiornato via GRU dai messaggi degli eventi: la "memoria storica" del comportamento di ogni entità. Espone z_mem e last_update. memory_dim è stata aumentata a 256 per gestire l'impronta comportamentale complessa. |
Hashed Identity (hash_emb) |
Embedding apprendibile via hashing deterministico della chiave (stable_hash, BLAKE2b). Mantiene induttività al 100% per i nodi nuovi e dà a ogni entità — incluse le risorse — un'identità distinguibile. Nota onesta: il delta di ablation è in attesa di rigenerazione (vedi §risultati) — le due serie presenti nel repo si contraddicevano e nessuna aveva un log a supporto. |
History features (compute_hist_feats) |
Per ogni evento [log1p(pair_count), log1p(src_count), pair/(src+1)]: contatori d'interazione causali e benign-gated (derivabili a runtime, non circolari). Iniettano il segnale di novità della coppia src→dst. Il delta di ablation è in attesa di rigenerazione (vedi §risultati). |
Kill-chain precursor (recent_alert, precursor_boost) |
Prior moltiplicativo serving-time che alza lo score di un'entità subito dopo un suo alert (recon→lateral), con decadimento 0.5^(Δt/half_life). Stato fuori dalla memoria TGN (il gate scarterebbe il precursore); non è un input addestrato. Delta di ablation in attesa di rigenerazione (vedi §risultati). |
Static node features (node_feat) |
Attributi statici ZTA per-nodo, buffer [num_nodes, 16]. Indici usati: [2] device tier, [3] inutilizzato (conteneva l'indice risorsa: leakage, rimosso), [4] resource risk (mirror di getResourceSensitivity/matrice_sicurezza), [5] source network internal/external (RFC1918, derivato dall'IP — feature, non gate), [14] trust_score. |
MessageNeighborLoader (neighbor_loader) |
Ring-buffer bounded in RAM con gli ultimi neighbor_size=30 vicini temporali per nodo. Abilita il message-passing sul vicinato storico — il segnale strutturale per il lateral movement — con memoria O(num_nodes·K·msg_dim) costante. Nessun database a grafo. |
GraphAttentionEmbedding (gnn) |
Reti multi-hop (num_hops=3) di TransformerConv (4 teste, con connessioni residuali) che calcolano l'embedding di nodo z attendendo sui vicini temporali estesi; edge_attr = encoding del tempo relativo Δt concatenato al messaggio storico dell'arco. |
Feature head (link_pred, LinkPredictor) |
MLP su [z_src, z_dst, cur_msg, feat_src, feat_dst, Δt_enc, history_feats]. Allenata con un obiettivo InfoNCE (ranking della dst vera sopra K casuali) + ancora BCE positiva + BCE contestuale. È la testa che — con memoria + history feats — porta il segnale lateral. |
Structural head (struct_proj, struct_scale) |
Similarità coseno scalata tra le proiezioni di z_src e z_dst. Nota onesta: nelle misure precedenti risultava marginale — candidata a semplificazione, da confermare con le ablation rigenerate. |
NodeRegistry (registry) |
Mappa le chiavi-entità esterne → slot di memoria, con ammissione dinamica di entità mai viste e eviction LRU. Su eviction azzera lo slot in memoria, feature statiche, message store e vicinato. |
| Calibrazione soglia | Replay di validazione con lo stesso gate del test (nessuna label nel gate), in due passate a punto fisso. Soglia primaria = quantile sul benigno signal-clean al target FPR (default 0.01), label-free. La soglia cost-sensitive (clean_fpr_cap=0.05) resta riportata come upper bound dichiarato: richiede eventi lateral etichettati nella finestra di validazione. |
| Anti-poisoning gate | Memoria e neighbour loader vengono aggiornati solo per gli eventi ammessi dal decisore esterno (OPA ALLOW) → la baseline non viene avvelenata da eventi ostili. Nella modalità OPA-less (/score) il gate è lo score del modello stesso; è disponibile ma non è il protocollo misurato. |
Il "buffer" della memoria storica e del vicinato (MessageNeighborLoader) non comporta mai swapping o caricamenti lenti. È costituito da grosse matrici pre-allocate fisse in RAM (es. [Capacità Totale Nodi, K]) fin dall'avvio del server. Ogni utente o IP possiede una sua "riga" privata all'interno di queste matrici. All'arrivo di una richiesta, il sistema esegue un accesso diretto (lookup) in tempo O(1) esclusivamente alla riga del nodo coinvolto, aggiornando gli eventi in modalità ring-buffer. La storia degli altri utenti non viene spostata, caricata o alterata.
⚠️ Portata esatta del claimO(1)/ stato limitato. Vale per i buffer tensoriali (memoria TGN, ring-buffer del vicinato, feature statiche), che sono pre-allocati a dimensione fissa. Non vale per l'intero stato del modello:
last_contact,pair_count,src_count,recent_alert(src/model/tgn.py) sono dizionari Python non limitati: crescono con ogni coppia(src, dst)mai committata, vengono persistiti nel checkpoint e sono ripuliti solo per-slot in eviction.- L'eviction non è O(1):
NodeRegistry._select_evictionè unminsu tutta la capacity con una sincronizzazione per elemento,MessageNeighborLoader.reset_nodefa una scansioneO(num_nodes × K)per invalidare i riferimenti entranti, e la ripulitura dei dizionari è lineare nella loro dimensione.- Le uniche latenze misurate nel repo sono P50 ≈ 9.6 ms / P99 ≈ 10.8 ms end-to-end HTTP (
docs/latex/report.tex) — non "frazioni di millisecondo", e comunque non riprodotte da un log versionato. Ogni richiesta esegue 5 espansioni del vicinato e 5 forward del GNN (uno per arco della catena).È un limite di scalabilità noto e dichiarato: il paper esce sulla versione vanilla.
NodeRegistrymappakey_user/key_device/key_source/key_dst/key_config→ slot di memoria (ammette entità nuove). La richiesta è la catena a 5 archi:source → config,config → device,config → user,device → usere l'accessouser → resource. Se una chiave manca, l'arco relativo viene gestito via fallback. Lo score finale è il max sugli archi presenti.- Il neighbour loader espande i nodi al loro vicinato temporale storico
(
n_id, edge_index, hist_t, hist_msg). embed(): legge la memoria, vi concatena l'identità di nodo e fa girare la GNN sui vicini reali → embeddingzconsapevole di identità e storia.score(): somma la feature head e la structural head → logit →anomaly score = 1 − σ(logit).- Il decisore esterno (OPA) risponde ALLOW/DENY. Su ALLOW si aggiorna
TGNMemorye si inserisce l'arco nel neighbour loader (predict-then-update); su DENY la memoria resta intatta. Il verdetto del modello viene riportato indietro (flagged) e arma il precursore kill-chain / abbassa il trust anche quando OPA ammette l'evento.
Train/serve consistency — equivalenza verificata, non codice condiviso. La valutazione offline (
train_tgn._replay) non chiama le primitive di serving: è una re-implementazione vettorizzata che valuta un blocco di eventi con una sola espansione del vicinato. Ciò che lega i due percorsi è un test:tests/verify_replay_batching.pyrigioca lo stesso stream evento per evento attraversoinfer_score/update_memorye verifica l'accordo con_replay(batch_size=1)entro 1e-5, sia sulla catena v4 a 5 archi sia sulla legacy v3 (misurato:max|Δ| = 3.1e-07). Revisioni precedenti di questo README affermavano che i due path condividessero il codice: non era vero, e l'harness verificava per giunta solo il ramo legacy.
Nota (Hashed Identity) — l'identità di nodo non è transduttiva, bensì generata tramite hashing deterministico della chiave (
stable_hash, BLAKE2b): coerente tra processi e riavvii (ilhash()builtin è salato per-processo e romperebbe la riproducibilità). Per entità note e non note al training fornisce un embedding coerente e scalabile (bucket), mantenendo il modello totalmente induttivo.
Il generatore (src/data/stream_synthetic.py) simula la catena v4 IP → config (JA3) → device →
utente → risorsa (smart working/roaming, NAT, device condivisi, cookie-wipe, tool/JA3 mai visti)
e produce, oltre alle
etichette binarie y, un vettore types per la valutazione per-classe e un bitmask
scenario (roaming / wiped / shared) per la valutazione per-scenario:
type |
Classe | Caratteristica | Note |
|---|---|---|---|
| 0 | benign | abituale o esplorazione autorizzata-non-abituale (benign_explore_prob) |
— |
| 1 | policy | ruolo/clearance/tier insufficienti | di OPA (bloccato a monte); non valore aggiunto del modello |
| 2 | contextual | JA3 rotto / alert Snort / sensori | banale: presa al ~97% dalla rule baseline |
| 3 | lateral | autorizzato ma non-abituale | target ML genuino: storia + memoria temporale + precursor kill-chain |
| 4 | credential theft | Un client/tool (config/JA3) diverso da quello abituale che riusa credenziali di un utente noto | target ML genuino (schema v4): visibile in particolare sul binding config → user (policy-clean, signal-clean) |
De-degenerazione. Il benigno ora compie a volte accessi autorizzati-non-abituali legittimi, quindi il lateral è feature-identico a un benigno non-abituale: l'unico discriminante è il pattern temporale (recon→lateral). Senza questo (
benign_explore_prob=0) il task sarebbe la tautologia «non-abituale ⟺ lateral». Vedidocs/inductive_testing.md.
🔴 STATO: le tabelle qui sotto NON sono riproducibili da HEAD e vanno rigenerate.
Il generatore è stato de-leakato. La revisione pre-submission ha misurato che l'arricchimento del dataset ("Phase 2": 1000 risorse procedurali, campionamento Zipf, colonne
bytes_*/http_status) aveva introdotto scorciatoie che rendevano il task banale:
classe best AUC a feature singola — prima dopo policy 0.930 0.859 (risk: semantico, dichiarato) contextual 0.926 0.898 (sonde Snort: by design) lateral 0.920 0.567 cred-theft 0.941 0.730 La causa principale:
node_feat[dst, 3]conteneva l'indice risorsa, e poiché il benigno si concentrava sulle risorse popolari (= indici bassi) mentre gli attacchi campionavano uniforme, quella singola colonna raggiungeva da sola AUC 0.92 su ogni classe — cioè quanto l'AUC lateral riportata dal modello. In più, quattro pattern a valore costante (http_status==0.4,bytes==(0.1,0.1),bytes_out==15.0,bytes==(0.2,0.5)) identificavano policy, cred-theft, exfil e i service account benigni con precision e recall del 100%.Le tabelle attuali precedono questo problema (generate il 2026-06-24, prima dell'arricchimento): sul generatore di allora
AUC(node_feat[dst,3])sul lateral era 0.4899, cioè caso — il valore riportato è genuinamente guadagnato dal modello. Ma quel generatore non esiste più, quindi i numeri non sono riproducibili da HEAD e vanno rifatti sul dataset corretto.Presidio permanente:
pytest tests/test_leakage_audit.py(CPU, pochi secondi) verifica su 3 seed che nessuna colonna d'input separi una classe da sola, che non esistano fingerprint a valore esatto, e che benigno e lateral condividano la marginale di destinazione. Le due classi su cui poggia il contributo — lateral e cred-theft — non hanno alcuna deroga.
Valutazione de-circolarizzata + de-degenerata sullo stream sintetico (num_events=200k,
3 seed [42,7,123] — media ± dev.std, FPR target 1%, split cronologico 70/10/20, solo benigno
in training, soglia calibrata sul benigno di validazione). Focus sul lateral (policy è di OPA,
contextual è banale). Tutte le baseline ricevono gli stessi segnali tabellari del TGN (feature
di storia causali + lo stesso prior precursor): così il divario col TGN isola il contributo della
macchina temporale-relazionale, non dei contatori.
| Modello (stessi segnali tabellari) | Agg AUC | Agg AP | lateral AUC | lateral Rec@1%FPR |
|---|---|---|---|---|
| Isolation Forest | 0.763 | 0.575 | 0.645 | 0.9% |
| One-Class SVM | 0.801 | 0.739 | 0.599 | 6.2% |
| Static GNN (grafo, no temporale) | 0.555 | 0.560 | 0.451 ≈ caso | 12.5% (spurio) |
| TGN (full, v3 per-cookie) |
0.965 | 0.922 | 0.913 | 16.6% |
| XGBoost (supervisionato, upper-bound) | 0.974 | 0.948 | 0.929 | 33.9% |
⚠️ Protocollo misto, da rigenerare. In questa tabella la riga TGN proviene da run v3 per-cookie, mentre tutte le baseline girano deployable (dev:guest, v4): righe prodotte sotto due configurazioni diverse, presentate come confronto. Il driver è stato corretto (run_panel_a_tgnesegue ora anche il TGN sotto il protocollo delle baseline), ma i numeri qui sopra sono i vecchi — vanno rigenerati insieme al resto.
Medie su 3 seed; dispersione (± dev.std) nella relazione tecnica (
docs/latex/report.tex, tab. baseline). XGBoost è supervisionato (vede le label): upper-bound di riferimento, fuori dal paradigma non supervisionato del TGN — non una baseline comparabile.
Schema v4 (5 nodi / 5 archi, nodo config/JA3 — 3 seed
[42,7,123], decisione cost-sensitive instradata). Il nodo Configuration alza il recall operativo sul lateral movement dal ~35.0% del v3 al ~57.7% (+0.227, oltre il rumore multi-seed), con Lateral AUC 0.913 → 0.930 e Agg AUC ~invariata (0.965 → 0.964). Migliora la rilevazione del credential theft (Recall +0.108 vs l'ablazione senza nodo config, su stream theft-rich). Non è un dominio uniforme: il trade-off è un FPR benigno instradato più alto (~5.0% → ~6.6%). Tabelle complete e validazioni mirate nella relazione tecnica (docs/latex/report.tex).Identità del device (deployable). Di default
guest_device_fallback=True: i dispositivi senza TPM collassano su un unico nododev:guestanziché tenere un'identità-cookie per macchina. Sullo stream sintetico è un miglioramento di Pareto (FPR benigno e varianza tra seed più bassi, niente falsi positivi da cookie-wipe) senza degradare la detection; il keying per-cookie resta attivabile (flag off) per l'attribuzione forense per-macchina.[storico, single-run]. Schema v3 (4 nodi): Lateral AUC ~0.894, Agg AUC ~0.952. Schema v2: Lateral AUC ~0.818, Agg AUC ~0.919. Run single-seed di setup precedenti (lo schema a 4 nodi usava il JA3 solo come bit di validità, mascherando tool nuovi su device noti e furti di credenziali da client differenti), mantenuti solo come contesto evolutivo; i numeri correnti in tabella sono multi-seed e differiscono di conseguenza.
- Lo Static GNN — stessi contatori + precursor, stessa struttura di grafo, ma senza la macchina temporale — sta a caso sul lateral (0.451). Il TGN arriva a 0.913±0.014 (3 seed): il segnale laterale vive nella memoria ricorrente + vicinato temporale, non nei contatori (che tutti hanno).
⚠️ I delta di ablation per componente sono RITIRATI in attesa di rigenerazione. Le due serie presenti nel repo si contraddicevano sulle stesse grandezze — README dava history +0.163 / hash +0.046 / precursor +0.013 / struct +0.007, mentredocs/lateral_movement.mdedocs/inductive_testing.mddavano history +0.066 / precursor +0.073 / hash −0.003 / struct −0.008 — e nessuna delle due ha un log a supporto (docs/latex/PROVENANCE.mdindica come sorgente "stdout"; non esiste nessun file di ablation intasks/runs/). Vanno rigenerate contests/ablations/run_ablations.py, log salvato, e riportate come Δ appaiati per seed con test di Wilcoxon e CI bootstrap (report_metrics.paired_delta). Vedi anche la nota sulla varianza run-to-run più sotto.- Recall@1%FPR ~16.6% resta basso (la soglia globale è dominata dalle classi facili); il 12.5% della Static GNN è spurio (AUC≈caso con soglia permissiva, non un segnale reale). Il segnale onesto è l'AUC 0.913 multi-seed (≫ caso); il «~40% recall» di vecchie misure era un artefatto circolare. La conversione in recall operativo passa per il routing cost-sensitive (Sez. soglia): sul test sintetico il recall laterale sale a ~35.0% (FPR benigno ~5.0%), e a ~57.7% con il nodo config (v4).
⚠️ Le barre d'errore pubblicate sottostimano la varianza reale — da rigenerare. Due run della stessa configurazione e dello stesso seed 42 (tasks/runs/panelB.jsonvstasks/runs/tgn_v*_percookie.log; stesso stream,n_lateral=3868en_benign=14555identici) differiscono di +0.038 di AUC lateral e +0.132 di recall lateral. Le dispersioni riportate (±0.013÷0.014 su AUC, ±0.034÷0.057 su recall) provengono da 3 seed × 1 run ciascuno, quindi confondono varianza tra-seed e varianza run-to-run e sottostimano l'errore. Conseguenza diretta: il Δ titolare +0.017 di lateral AUC v3→v4 è dentro il rumore e non è al momento stabilito (il Δ di recall +0.227 è molto più grande e probabilmente sopravvive, ma va ri-misurato).Correzioni già applicate: seeding completo + algoritmi deterministici in
train_tgn,mean_stdconddof=1(le σ pubblicate erano ~18% troppo strette), epaired_deltacon Wilcoxon appaiato + CI bootstrap al posto della regola|Δ| > max(σ), che non era un test. Resta da eseguire la griglia a ≥5 seed × 3 repliche.
Dettagli su de-circolarizzazione, de-degenerazione, ablation multi-seed, cold-start e
anti-poisoning in 👉 docs/inductive_testing.md e
docs/lateral_movement.md. Riproduzione: profili Compose
training-tgn, baseline-iforest, baseline-ocsvm, baseline-gnn, baseline-xgboost,
ablations, config-eval, guest-device-eval, arch-sweep, eval-lanl, verify-tgn. Le
tabelle della relazione tecnica si rigenerano (multi-seed) col profilo regen-report.
Da leggere prima di trattare le metriche come garanzie di produzione:
- Validità esterna. Le metriche pubblicate sono su stream sintetico. Selezione dei
dataset reali e mapping campo-per-campo sulla catena a 5 nodi in
👉
docs/datasets.md. In sintesi: nessun dataset ZTA pubblico esiste e nessuno consegna insieme identità utente e fingerprint TLS. PicoDomain è l'unico corpus in cui tutti e 5 i nodi poggiano su campi reali (ssl.logportaja3): harnesstests/eval_picodomain.py+tests/datasets/picodomain.py, profiloeval-picodomain, contratto verificato datests/test_picodomain_mapping.py. Esiste anche un harness su LANL auth (tests/eval_lanl.py, profiloeval-lanl), ma lì il nodo config degenera e la classe credential-theft non è valutabile: è di fatto l'ablazione «senza nodo config», non un benchmark alla pari. Nessuno dei due ha ancora risultati publication-grade. - Anti-poisoning gate auto-deciso. Memoria/vicinato si aggiornano solo per eventi
scorati benigni. Conseguenze intrinseche: un attaccante stealthy scorato benigno
avvelena la baseline; un benigno scorato anomalo non viene mai appreso (starvation).
Mitigazione demandata all'orchestrator: OPA come vero decisore (
/infer→/update) e un grace period breve per i nuovi nodi (vedidocs/orchestrator_integration.md). - Endpoint non autenticati.
/update,/score,/persistnon hanno auth: chiunque raggiunga il servizio può alterare lo stato, bypassando il gate. Il design assume un orchestrator fidato su rete privata; non esporre il servizio senza TLS + autenticazione. - Recall operativo del lateral (non "risolto"). Il routing cost-sensitive (
cost_ratio=20.0,clean_fpr_cap=0.05) converte il ranking (lateral AUC ~0.913) in recall operativo, ma resta limitato: sul test sintetico il recall laterale passa da ~16.6% (soglia globale 1% FPR) a ~35.0% (v3, decisione instradata, FPR benigno ~5.0%) e fino a ~57.7% con il nodo config (v4, FPR ~6.6%). È un trade-off regolabile viacost_ratio/clean_fpr_cap, non un problema chiuso. - Precursor = euristica. Il prior kill-chain assume che il lateral segua un recon che fa scattare Snort sullo stesso IP. Regge nel generatore; un attaccante che evita il recon rumoroso lo aggira. È un prior additivo onesto, non una garanzia.
- Cold start. Una nuova entità senza storia non ha «abitudini» da cui deviare. Nel nostro
stream tutti i laterali cadono su entità già calde (
n_cold=0), quindi qui non è il collo di bottiglia — ma in deployment un'entità fredda non è coperta finché non accumula interazioni.
Tutte le fasi girano via Docker Compose su GPU (CUDA 13, RTX Blackwell). Dettagli in
docs/docker.md.
# Training del modello streaming temporale (TGN)
docker compose --profile training-tgn up
# Verifica della correttezza del serving streaming (richiede gli artifact in public/)
docker compose --profile verify-tgn up
# Servizio di inferenza HTTP long-running (REST/JSON su :8088, per l'orchestrator ZTA)
docker compose --profile serve-tgn upIn alternativa, con comandi Docker diretti:
docker build -f docker/Dockerfile -t graphagate .
docker run --rm --gpus all -v "$PWD/public:/app/public" graphagate # train_tgn
docker run --rm --gpus all -v "$PWD/public:/app/public" graphagate graphagate.verify_tgnIl manoscritto accademico in formato IEEEtran (docs/paper/main.tex) può essere compilato automaticamente tramite gli script dedicati in scripts/, che rilevano il motore LaTeX installato (pdflatex, latexmk, xelatex, lualatex, tectonic o fallback Docker texlive/texlive), risolvono la bibliografia con BibTeX e rimuovono i file intermedi di build.
# Compilazione automatica + pulizia dei file intermedi (.aux, .log, .bbl, ecc.)
.\scripts\build_paper.ps1
# Per forzare un motore specifico (es. pdflatex) o mantenere i file intermedi
.\scripts\build_paper.ps1 -Engine pdflatex -KeepAux
# Solo pulizia dei file temporanei
.\scripts\build_paper.ps1 -CleanOnly# Rendere eseguibile lo script (la prima volta)
chmod +x ./scripts/build_paper.sh
# Compilazione automatica + pulizia
./scripts/build_paper.sh
# Per forzare un motore specifico o mantenere i file intermedi
./scripts/build_paper.sh --engine pdflatex --keep-aux
# Solo pulizia dei file temporanei
./scripts/build_paper.sh --clean-onlyIl PDF generato finale viene salvato direttamente in docs/paper/main.pdf.
docs/paper/ # Manoscritto accademico IEEEtran (main.tex, results.tex, refs.bib)
scripts/ # Script di utilità e build (build_paper.ps1, build_paper.sh)
src/config.py # TGN hyper-parameters and artifact paths
src/data/stream_synthetic.py # streaming mock data generator (policy / contextual / lateral anomalies)
src/model/tgn.py # TGN architecture: TGNMemory + identity + GNN + dual scorer
src/model/neighbor.py # MessageNeighborLoader: bounded in-RAM temporal neighbour store
src/model/registry.py # dynamic NodeRegistry: external entity keys -> memory slots
src/train_tgn.py # self-supervised training + threshold calibration + per-class eval
src/serve_tgn.py # serving primitives / persistence (load_model, score_event, commit_event)
src/serve_api.py # REST/JSON inference microservice (FastAPI) — deployable service
src/verify_tgn.py # serving-path verification harness
docker/Dockerfile # GPU image for train_tgn / verify_tgn
public/ # artifacts: tgn_checkpoint.pt, tgn_stats.json
L'integrazione con l'orchestrator ZTA / Policy Decision Point (OPA) è descritta in
docs/orchestrator_integration.md: endpoint HTTP,
schema delle richieste e flusso anti-poisoning con OPA (/infer → OPA → /update).
Usato come git submodule, il servizio si referenzia nel docker-compose.yml della
soluzione ZTA puntando al Dockerfile del submodule. Prerequisito: aver prodotto una volta
gli artifact con il profilo training-tgn (finiscono in public/).
graphagate-inference:
build:
context: ./graphagate # path del submodule
dockerfile: docker/Dockerfile
command: ["graphagate.serve_api"] # ENTRYPOINT è ["python","-m"]
volumes:
- ./graphagate/public:/app/public # checkpoint + stats (artifact del training)
ports:
- "8088:8088"
healthcheck: # readiness: GET /health
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8088/health').status==200 else 1)"]
interval: 30s
retries: 3
start_period: 40s
# GPU opzionale per l'inferenza; un solo container (stato mutabile in RAM).
orchestrator:
# ...
depends_on:
graphagate-inference:
condition: service_healthy # parte solo a modello caricatoL'orchestrator chiama gli endpoint via HTTP (/infer → OPA → /update); esempio di
client Go e variabili d'ambiente in
docs/orchestrator_integration.md.
