feat(ml): replace NetworkX/Louvain with PyTorch Geometric GraphSAGE for fleet detection

Rewrite fleet.py to use a GNN-based approach: nodes are src_ip with ML feature
vectors, edges connect IPs sharing (JA4, ASN) pairs, GraphSAGE (2 SAGEConv
layers, in→64→32) produces 32D embeddings clustered by HDBSCAN. PyG NeighborLoader
activates for >50k nodes. Update thesis docs (§5.2, §6.4, §2, §8) to reflect
GraphSAGE architecture and PyG scalability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-04-13 15:45:34 +02:00
parent c1821dcbc4
commit c6cb12981c
8 changed files with 378 additions and 264 deletions

View File

@ -16,7 +16,7 @@ Les huit techniques couvrent l'intégralité de la chaîne de détection : analy
| Section | Technique | Statut |
|---------|-----------|--------|
| 5.1 | Path Sequence Entropy | `[impl.]` |
| 5.1 | Session Transformer Embedding | `[impl.]` |
| 5.2 | Bipartite Bot Fleet Detection | `[impl.]` |
| 5.3 | Request Cadence Fingerprint | `[impl.]` |
| 5.4 | Resource Dependency Tree | `[impl.]` |
@ -27,94 +27,83 @@ Les huit techniques couvrent l'intégralité de la chaîne de détection : analy
---
### 5.1 Entropie de séquence de chemins (Path Sequence Entropy) `[impl.]`
### 5.1 Encodage contextuel des séquences de navigation (Session Transformer) `[impl.]`
#### Constat et motivation
La feature `path_diversity_ratio` mesure la **diversité** des chemins parcourus (nombre de chemins distincts / nombre total de requêtes), mais non leur **ordre**. Cette distinction est fondamentale : un crawler sophistiqué peut randomiser l'ordre de visite des pages pour augmenter la diversité apparente, sans reproduire les transitions naturelles d'un comportement humain.
La feature `path_diversity_ratio` mesure la **diversité** des chemins parcourus (nombre de chemins distincts / nombre total de requêtes), mais non leur **ordre**. La version précédente de cette technique (§5.1 v1) utilisait une chaîne de Markov du premier ordre pour capturer l'ordre des transitions, réduite à un scalaire unique (`path_transition_entropy`). Cette approche présentait une **limitation fondamentale** : la propriété markovienne (l'état suivant ne dépend que de l'état courant) rend impossible la capture d'un contexte long.
La navigation humaine suit des patterns séquentiels prévisibles déterminés par l'architecture du site : page d'accueil → catégorie → produit → panier d'achat. Ces transitions ont une structure probabiliste stable que les crawlers systématiques ne reproduisent pas, même avec randomisation.
Or, la navigation humaine est intrinsèquement hiérarchique : une page produit n'a pas la même signification selon qu'elle est atteinte depuis la page d'accueil, depuis un moteur de recherche, ou depuis un lien de recommendation. Un crawler avancé peut reproduire les transitions bigrammes d'un humain (Markov O(1)), mais échouera à reproduire les dépendances à longue portée que seul un modèle attentionnel peut encoder.
#### Modèle mathématique : chaîne de Markov du premier ordre
#### Limites de l'approche Markov O(1)
Une **chaîne de Markov du premier ordre** modélise un système dans lequel l'état suivant dépend uniquement de l'état courant (propriété d'absence de mémoire, aussi appelée propriété markovienne). Pour les séquences de chemins, chaque chemin est un état, et la probabilité de transition est :
L'approche précédente modélisait les transitions de chemins par une chaîne de Markov du premier ordre :
```
P(chemin_j | chemin_i) = count(i → j) / count(i)
```
`count(i → j)` est le nombre de fois que le chemin `i` est immédiatement suivi du chemin `j`, et `count(i)` est le nombre total de départs depuis `i`.
L'entropie de Shannon sur cette matrice, `H = -Σ p(a→b) × log₂(p(a→b))`, condense **toute la structure séquentielle** en un scalaire unique. Trois insuffisances majeures :
L'**entropie de la matrice de transition** mesure l'imprévisibilité des transitions :
1. **Perte du contexte long** : la propriété markovienne impose que `P(path_t | path_{t-1})` ignore tout l'historique au-delà de `t-1`. Un bot qui alterne correctement les paires de transitions mais avec un pattern périodique (A→B→C→A→B→C…) sera indiscernable d'un humain.
2. **Réduction scalaire** : l'entropie de Shannon projette la matrice de transition en un seul nombre, détruisant la richesse structurelle de la distribution.
3. **Absence de signal temporel** : la chaîne de Markov ignore les intervalles inter-requêtes, alors que le rythme de navigation est un discriminant puissant entre humains et bots (cf. §5.3 — cadence fingerprint).
```
H_transition = -Σ_{i,j} P(p_i → p_j) × log₂(P(p_i → p_j))
```
Ces limites motivent le passage à un **modèle attentionnel** capable d'encoder conjointement la séquence des chemins, les méthodes HTTP et le rythme temporel.
Interprétation :
- **Entropie élevée** : transitions imprévisibles — caractéristique d'une navigation humaine organique avec des chemins variés
- **Entropie faible** : transitions prévisibles — caractéristique d'un crawler systématique (lexicographique, profondeur d'abord)
- **Entropie nulle** : transition unique constante — bot répétant exactement le même chemin
#### Architecture du Session Transformer
#### Résistance à la rotation de diversité
Le modèle `SessionTransformer` est un encoder Transformer léger qui produit un **embedding dense de dimension 32** pour chaque session. L'architecture comporte quatre étapes :
Un bot qui randomise aléatoirement l'ordre de ses chemins augmente `path_diversity_ratio` mais produit une matrice de transition **quasi-uniforme** (entropie maximale). Or, la navigation humaine réelle possède des transitions **structurées** : les transitions produit → panier sont fréquentes, les transitions panier → page d'accueil sont rares. L'entropie maximale (uniforme) est donc elle-même un signal anormal, distinguable de l'entropie modérée mais structurée de la navigation humaine.
**1. Tokenisation multi-signal**
La combinaison `(path_diversity_ratio, path_transition_entropy)` forme un espace bidimensionnel où les clusters bots et humains sont mieux séparés qu'avec une feature seule.
Chaque requête dans la séquence est représentée par la somme de trois embeddings :
| Signal | Encodage | Dimension |
|--------|----------|-----------|
| Chemin URL | `hash(path) mod V``nn.Embedding(V, d_model)` | 64 |
| Méthode HTTP | `enum(GET=0, POST=1, …)``nn.Embedding(8, d_model)` | 64 |
| Délai inter-requête Δt | `log1p(Δt_ms) / 10``nn.Linear(1, d_model)` | 64 |
Le vocabulaire des chemins `V` est fixé à 65 536 (hash modulo). La normalisation `log1p/10` compresse la distribution heavy-tailed des intervalles (quelques ms à plusieurs minutes) en une plage stable pour l'optimisation.
**2. Transformer Encoder**
- 2 couches `nn.TransformerEncoderLayer`, 4 têtes d'attention, `d_model=64`, `dim_feedforward=256`
- Positional encoding sinusoïdal injecté en addition (Vaswani et al., 2017)
- Le mécanisme de *self-attention* permet à chaque position d'attendre sur **toutes** les autres positions de la séquence, capturant les dépendances longues que la chaîne de Markov ne pouvait pas modéliser
**3. Mean Pooling**
La sortie du Transformer `(B, S, 64)` est moyennée sur l'axe temporel : `z = mean(outputs, dim=1) → (B, 64)`. Le *mean pooling* agrège l'information de toute la séquence en un vecteur de session fixe, indépendamment de la longueur.
**4. Projection linéaire**
`head = nn.Linear(64, 32)` projette le vecteur de session en 32 dimensions : `emb = head(z) → (B, 32)`.
Les 32 dimensions sont exposées comme colonnes `seq_emb_0` à `seq_emb_31` dans le DataFrame de features.
#### Robustesse
| Condition | Comportement |
|-----------|-------------|
| Session ≤ 1 requête | Vecteur nul (32 zéros) — pas assez de contexte |
| Session > 512 requêtes | Troncation aux 512 dernières requêtes |
| Fichier de poids absent | Fallback à vecteurs nuls + avertissement journalisé |
| PyTorch non installé | Fallback à vecteurs nuls (déploiement minimal) |
#### Implémentation
**Table ClickHouse** : `agg_path_sequences_1h`
- Colonnes : `(src_ip, ja4, session_id, groupArray(path) AS path_sequence)`
- Agrégation par session sur fenêtre glissante d'une heure
**Modèle** : `bot_detector.session_transformer.SessionTransformer` (PyTorch)
**Vue** : `view_thesis_features_1h`
- Colonne : `path_transition_entropy`
**Inference** : `bot_detector.session_transformer.extract_sequence_embeddings(df_sessions, client)` — requête les logs bruts depuis `ja4_logs.http_logs`, groupe par `(src_ip, ja4, host)`, encode les séquences, et retourne un DataFrame avec colonnes `src_ip, ja4, host, seq_emb_0..seq_emb_31`.
**SQL de calcul** :
```sql
-- Normalisation des préfixes à profondeur 2
-- ex. /shop/product/123 → /shop/product
WITH normalized_paths AS (
SELECT
session_id,
arrayMap(p -> arrayStringConcat(
arraySlice(splitByChar('/', p), 1, 3), '/'), path_sequence
) AS norm_seq
FROM agg_path_sequences_1h
),
-- Calcul des transitions
transitions AS (
SELECT
session_id,
arrayZip(
arraySlice(norm_seq, 1, length(norm_seq) - 1),
arraySlice(norm_seq, 2)
) AS transition_pairs
FROM normalized_paths
)
-- L'entropie finale est calculée via UDF Python (scipy.stats.entropy)
-- ou approximée par la formule SQL suivante :
SELECT
session_id,
-sum(p * log2(p)) AS path_transition_entropy
FROM (
SELECT
session_id,
count() / sum(count()) OVER (PARTITION BY session_id) AS p
FROM transitions
ARRAY JOIN transition_pairs AS tp
GROUP BY session_id, tp
)
GROUP BY session_id;
```
La feature `path_transition_entropy` est intégrée dans le vecteur feature de `bot_detector` (famille F7 — Comportement navigation).
**Intégration** : les 32 colonnes `seq_emb_*` remplacent `path_transition_entropy` et `cadence_cv` dans les listes `FEATURES` et `FEATURES_COMPLET` du module `preprocessing`. L'embedding est injecté dans le cycle principal (`cycle.py`) avant l'appel à `preprocess_df`.
**Poids** : stockés au chemin configuré par `SESSION_TRANSFORMER_PATH` (défaut : `{MODEL_DIR}/session_transformer.pt`).
---
### 5.2 Graphe de co-occurrence JA4×ASN (Bipartite Bot Fleet Detection) `[impl.]`
### 5.2 Détection de flottes par apprentissage de représentations de graphe (GraphSAGE) `[impl.]`
#### Constat et motivation
@ -122,89 +111,86 @@ La feature `ja4_asn_concentration` mesure la concentration d'une paire (JA4, ASN
Un botnet distribuant ses requêtes sur 20 ASN différents, avec 5 JA4 distincts par ASN, produira 100 paires (JA4, ASN) différentes, chacune avec une concentration faible — indétectable par `ja4_asn_concentration` seul. Pourtant, ces 5 JA4 co-apparaissent systématiquement dans les mêmes ASN, formant une signature de flotte détectable par analyse de graphe.
#### Modèle mathématique : graphe bipartite et détection de communautés
**Limite de Louvain** : une approche classique consiste à construire un graphe bipartite JA4×ASN puis à projeter et détecter des communautés via l'algorithme de Louvain ([Blondel et al., 2008](https://arxiv.org/abs/0803.0476)). Cette méthode ne exploite que la **topologie des arêtes** (modularité) et **ignore totalement les features des nœuds** — le profil comportemental de chaque IP (nombre de requêtes, ratio POST, diversité TLS, etc.). Deux IPs aux comportements radicalement différents (un scraper agressif vs. un navigateur légitime derrière un CDN) peuvent être regroupées dans la même communauté simplement parce qu'elles partagent un (JA4, ASN) commun. De plus, Louvain opère sur des structures NetworkX en mémoire, ce qui limite la scalabilité (cf. §6.4).
Un **graphe bipartite** G = (U V, E) est un graphe dont les sommets se répartissent en deux ensembles disjoints (ici : JA4 fingerprints **U** et ASNs **V**) où les arêtes ne connectent que des sommets d'ensembles différents. Une arête (u, v) ∈ E existe si ≥ N IPs utilisent le JA4 u depuis l'ASN v dans la fenêtre temporelle considérée.
#### Modèle mathématique : graphe d'IPs et plongements GNN
**Projection sur l'espace JA4** : le graphe projeté G_JA4 = (U, E') est défini par (u₁, u₂) ∈ E' si ∃ v ∈ V tel que (u₁, v) ∈ E et (u₂, v) ∈ E. Deux JA4 partagent une arête si et seulement si ils co-apparaissent dans le même ASN.
L'approche proposée modélise directement les **IPs comme nœuds** d'un graphe, où les **features** de chaque nœud sont le vecteur ML agrégé de l'IP (moyenne des features numériques sur les sessions du cycle courant, normalisées par min-max).
**Détection de communautés** : une communauté est un sous-ensemble de sommets plus densément connectés entre eux qu'avec le reste du graphe. Dans le graphe projeté G_JA4, une communauté dense de JA4 indique un botnet utilisant plusieurs empreintes JA4 mais se coordonnant via une infrastructure ASN partagée.
**Construction du graphe** G = (V, E) :
- **Nœuds** V : ensemble des `src_ip` uniques dans la fenêtre temporelle
- **Features** x ∈ ℝᵈ : vecteur ML de dimension d (features numériques du pipeline, typiquement d ≈ 3060)
- **Arêtes** (u, v) ∈ E : une arête relie IP_u et IP_v si elles co-occurent dans au moins un groupe (JA4, ASN) comptant ≥ N IPs distinctes (N = `FLEET_MIN_IPS`, défaut 3)
L'implémentation utilise **Louvain** ([Blondel et al., 2008](https://arxiv.org/abs/0803.0476)) via la bibliothèque `python-louvain` pour la détection de communautés sur le graphe projeté pondéré. L'algorithme de Louvain optimise la modularité du graphe par agrégation hiérarchique itérative, identifiant les regroupements de JA4 partageant un profil de co-occurrence ASN similaire. En l'absence de `python-louvain`, un fallback vers les **composantes connexes** de NetworkX est utilisé.
Cette modélisation permet au GNN de combiner deux sources d'information :
1. **Structure du graphe** : qui communique avec qui (co-occurrence JA4×ASN)
2. **Features des nœuds** : comment chaque IP se comporte (profil ML)
**GraphSAGE** ([Hamilton et al., 2017](https://arxiv.org/abs/1706.02216)) apprend une représentation latente h_v pour chaque nœud v en agrégeant itérativement les features de ses voisins :
```
h_v^(k) = σ(W^(k) · CONCAT(h_v^(k-1), AGGREGATE({h_u^(k-1) ∀ u ∈ N(v)})))
```
où N(v) est le voisinage de v, W^(k) sont les poids appris à la couche k, et AGGREGATE est la fonction d'agrégation (moyenne chez SAGEConv). L'implémentation utilise 2 couches SAGEConv (in_channels → 64 → 32) avec activation ReLU et Dropout(0.1) entre les couches, produisant un **embedding 32D** par IP.
**Clustering dans l'espace latent** : les embeddings 32D sont clusterisés par HDBSCAN ([McInnes et al., 2017](https://arxiv.org/abs/1705.07302)) ou DBSCAN en fallback. Contrairement à Louvain qui partitionne sur la seule topologie, ce pipeline segmente les IPs dans un **espace latent conjointement informé par la structure du graphe et les features comportementales** — deux IPs aux features similaires mais sans co-occurrence directe peuvent tout de même être rapprochées via leurs voisinages respectifs (effet de propagation du GNN).
**Formule du score de flotte** :
```
fleet_score = community_size × edge_density / log2(n_asn + 2)
fleet_score = cluster_size × compactness / log2(n_asn + 2)
```
où :
- `community_size` : nombre de JA4 dans la communauté détectée
- `edge_density` : densité du sous-graphe induit (arêtes observées / arêtes possibles)
- `log(nb_ASN)` : terme de normalisation pour pénaliser les grandes flottes trop dispersées géographiquement (signal d'infrastructure légitime CDN)
- `cluster_size` : nombre d'IPs dans le cluster détecté
- `compactness` = 1 / (1 + d̄) où d̄ est la distance euclidienne moyenne au centroïde du cluster dans l'espace latent 32D
- `log2(n_asn + 2)` : terme de normalisation pour pénaliser les clusters dispersés sur de nombreux ASN (signal d'infrastructure légitime CDN)
Une valeur de `fleet_score` élevée (> seuil empirique déterminé en production) déclenche un malus sur le score d'anomalie de toutes les IPs des communautés concernées.
Une valeur de `fleet_score` élevée (`FLEET_SCORE_THRESHOLD`, défaut 2.0) déclenche un malus sur le score d'anomalie de toutes les IPs du cluster concerné.
#### Implémentation
**Module** : `fleet.py` dans `bot_detector`
**Dépendance** : `torch_geometric` (SAGEConv, NeighborLoader pour le batching)
```python
import networkx as nx
from networkx.algorithms import bipartite
from torch_geometric.nn import SAGEConv
import torch.nn as nn
def build_fleet_graph(df: pd.DataFrame, min_ips: int = 3):
"""Construit le graphe bipartite JA4 × ASN."""
G = nx.Graph()
edge_weights = (
df.groupby(['ja4', 'asn_number'])['src_ip'].nunique()
.reset_index(name='n_ips')
)
edge_weights = edge_weights[edge_weights['n_ips'] >= min_ips]
for _, row in edge_weights.iterrows():
G.add_edge(f"ja4:{row['ja4']}", f"asn:{row['asn_number']}",
weight=int(row['n_ips']))
return G, ja4_nodes, asn_nodes
class GraphSAGE(nn.Module):
def __init__(self, in_ch, hidden_ch=64, out_ch=32, dropout=0.1):
super().__init__()
self.conv1 = SAGEConv(in_ch, hidden_ch)
self.conv2 = SAGEConv(hidden_ch, out_ch)
self.drop = nn.Dropout(dropout)
def forward(self, x, edge_index):
x = self.conv1(x, edge_index).relu()
x = self.drop(x)
x = self.conv2(x, edge_index)
return x
def detect_fleet_communities(df: pd.DataFrame) -> dict:
"""Analyse le graphe et retourne {src_ip: fleet_score}."""
G, ja4_nodes, asn_nodes = build_fleet_graph(df)
# Projection bipartite : graphe des JA4 partageant des ASN
G_ja4 = bipartite.weighted_projected_graph(G, ja4_nodes)
# Détection de communautés (Louvain ou composantes connexes en fallback)
try:
from community import best_partition as louvain_partition
partition = louvain_partition(G_ja4, weight='weight', random_state=42)
except ImportError:
communities = {i: set(c) for i, c in enumerate(nx.connected_components(G_ja4))}
# fleet_score = taille × densité / log2(n_asn + 2)
for cid, members in communities.items():
score = len(members) * density / max(np.log2(n_asn + 2), 0.1)
for ja4_node in members:
fleet_scores[ja4_node.replace('ja4:', '')] = score
return ip_scores
matrix = np.zeros((len(ja4_nodes), len(asn_nodes)))
for i, ja4 in enumerate(ja4_nodes):
for j, asn in enumerate(asn_nodes):
if G.has_edge(ja4, asn):
matrix[i, j] = G[ja4][asn]['weight']
return ja4_nodes, matrix
def detect_fleets(sessions: pd.DataFrame) -> pd.DataFrame:
G = build_bipartite_graph(sessions)
ja4_nodes, matrix = project_ja4(G)
if len(ja4_nodes) < 5:
return pd.DataFrame()
clusterer = HDBSCAN(min_cluster_size=3, metric='jaccard')
labels = clusterer.fit_predict(matrix > 0)
# Calcul fleet_score par cluster...
"""Analyse le graphe d'IPs via GraphSAGE + HDBSCAN."""
# 1. Construire le graphe : nœuds=IP, features=vecteur ML, arêtes=co-occurrence
unique_ips, node_features, edge_index = _build_ip_graph(df)
# 2. Inférence GraphSAGE → embeddings 32D par IP
embeddings = _infer_embeddings(node_features, edge_index)
# 3. Clustering HDBSCAN sur les embeddings
labels = HDBSCAN(min_cluster_size=3, metric='euclidean').fit_predict(embeddings)
# 4. fleet_score = cluster_size × compactness / log2(n_asn + 2)
return {ip: {"cluster_id": cid, "fleet_score": score}
for ip, cid, score in results}
```
**Scalabilité** : pour les graphes de plus de 50 000 nœuds, le module active automatiquement le **Neighbor Sampling** via `torch_geometric.loader.NeighborLoader` (échantillonnage stochastique du voisinage, batch_size=4096), permettant de traiter des graphes de millions de nœuds sans charger l'intégralité en mémoire GPU (détail §6.4).
**Sortie** : les résultats de la détection de flotte sont intégrés directement dans le DataFrame du cycle courant via deux colonnes additionnelles :
- `fleet_score` : score de la communauté à laquelle appartient l'IP (0 si aucune flotte détectée)
- `fleet_score` : score du cluster auquel appartient l'IP (0 si aucune flotte détectée)
- `fleet_campaign_flag` : 1 si `fleet_score ≥ FLEET_SCORE_THRESHOLD` (défaut : 2.0)
Les IPs appartenant à des communautés avec `fleet_score` élevé reçoivent un **malus de score d'anomalie** dans le pipeline final (ajout d'une constante au score brut EIF avant application de la fusion LR).
Les IPs appartenant à des clusters avec `fleet_score` élevé reçoivent un **malus de score d'anomalie** dans le pipeline final (ajout d'une constante au score brut EIF avant application de la fusion LR).
---