feat: add ja4ebpf service — eBPF-based TLS/TCP fingerprinting daemon

- TC ingress hook captures TCP SYN (L3/L4) and TLS ClientHello
- Uprobes on SSL_read/SSL_set_fd capture decrypted TLS data
- Kprobes on accept4 correlate socket FDs to client IP:port
- JA4 fingerprint computed from parsed TLS ClientHello
- HTTP/2 SETTINGS and WINDOW_UPDATE extracted from decrypted streams
- Session manager with sharded map (256 shards) and GC goroutine
- Slowloris detection: sessions with no requests after 10s threshold
- ClickHouse batch writer to ja4_logs.http_logs_raw (raw_json)
- All tests pass: 17 parser + 10 correlation tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-11 22:43:26 +02:00
parent 7eb3ad21fd
commit a1e4c1dad5
24 changed files with 3984 additions and 0 deletions

View File

@ -0,0 +1,206 @@
package correlation_test
import (
"context"
"testing"
"time"
"github.com/antitbone/ja4/ja4ebpf/internal/correlation"
)
func TestSessionStateAddRequest(t *testing.T) {
s := &correlation.SessionState{
Key: correlation.SessionKey{SrcIP: [4]byte{1, 2, 3, 4}, SrcPort: 12345},
FirstSeen: time.Now(),
LastActivity: time.Now(),
}
req := correlation.HTTPRequest{
Method: "GET",
Path: "/api/test",
Timestamp: time.Now(),
}
s.AddRequest(req)
if len(s.Requests) != 1 {
t.Errorf("attendu 1 requête, obtenu %d", len(s.Requests))
}
if s.Requests[0].Method != "GET" {
t.Errorf("méthode attendue GET, obtenue %s", s.Requests[0].Method)
}
}
func TestSessionStateMultipleRequests(t *testing.T) {
s := &correlation.SessionState{
FirstSeen: time.Now(),
LastActivity: time.Now(),
}
for i := 0; i < 3; i++ {
s.AddRequest(correlation.HTTPRequest{
Method: "GET",
Path: "/path",
Timestamp: time.Now(),
})
}
if len(s.Requests) != 3 {
t.Errorf("attendu 3 requêtes, obtenu %d", len(s.Requests))
}
}
func TestSessionStateIsExpired(t *testing.T) {
past := time.Now().Add(-1 * time.Second)
s := &correlation.SessionState{
LastActivity: past,
}
if !s.IsExpired(500 * time.Millisecond) {
t.Error("session inactive depuis 1s doit être expirée avec timeout 500ms")
}
if s.IsExpired(2 * time.Second) {
t.Error("session inactive depuis 1s ne doit pas être expirée avec timeout 2s")
}
}
func TestSessionStateIsSlowlorisOpenConnection(t *testing.T) {
s := &correlation.SessionState{
FirstSeen: time.Now().Add(-15 * time.Second),
LastActivity: time.Now(),
}
// Connexion ouverte sans requête depuis 15s → Slowloris
if !s.IsSlowloris(10 * time.Second) {
t.Error("connexion sans requête depuis 15s doit être détectée comme Slowloris")
}
}
func TestSessionStateIsSlowlorisWithRequest(t *testing.T) {
s := &correlation.SessionState{
FirstSeen: time.Now().Add(-30 * time.Second),
LastActivity: time.Now(),
}
// Requête complète présente → pas Slowloris (len(Requests) > 0)
s.AddRequest(correlation.HTTPRequest{
Method: "GET",
Path: "/fast",
Timestamp: time.Now(),
})
if s.IsSlowloris(10 * time.Second) {
t.Error("session avec requête ne doit pas être détectée comme Slowloris")
}
}
func TestSessionStateIsSlowlorisNotYet(t *testing.T) {
s := &correlation.SessionState{
FirstSeen: time.Now().Add(-5 * time.Second),
LastActivity: time.Now(),
}
// Connexion ouverte depuis 5s seulement, seuil 10s → pas encore Slowloris
if s.IsSlowloris(10 * time.Second) {
t.Error("connexion de 5s ne doit pas être Slowloris avec seuil 10s")
}
}
// ── Tests du Manager ──────────────────────────────────────────────────────
func TestManagerGetOrCreate(t *testing.T) {
mgr := correlation.NewManager(500 * time.Millisecond)
defer mgr.Close()
key := correlation.SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 54321}
s1 := mgr.GetOrCreate(key)
s2 := mgr.GetOrCreate(key)
if s1 != s2 {
t.Error("GetOrCreate doit retourner la même session pour la même clé")
}
}
func TestManagerGetOrCreateDifferentKeys(t *testing.T) {
mgr := correlation.NewManager(500 * time.Millisecond)
defer mgr.Close()
key1 := correlation.SessionKey{SrcIP: [4]byte{10, 0, 0, 1}, SrcPort: 1000}
key2 := correlation.SessionKey{SrcIP: [4]byte{10, 0, 0, 2}, SrcPort: 1000}
s1 := mgr.GetOrCreate(key1)
s2 := mgr.GetOrCreate(key2)
if s1 == s2 {
t.Error("GetOrCreate doit retourner des sessions différentes pour des clés différentes")
}
}
func TestManagerUpdate(t *testing.T) {
mgr := correlation.NewManager(500 * time.Millisecond)
defer mgr.Close()
key := correlation.SessionKey{SrcIP: [4]byte{192, 168, 1, 1}, SrcPort: 8080}
mgr.Update(key, func(s *correlation.SessionState) {
s.L3L4 = &correlation.L3L4{TTL: 64}
})
s := mgr.GetOrCreate(key)
if s.L3L4 == nil {
t.Fatal("L3L4 doit être défini après Update")
}
if s.L3L4.TTL != 64 {
t.Errorf("TTL attendu 64, obtenu %d", s.L3L4.TTL)
}
}
func TestManagerGCExpiresOldSessions(t *testing.T) {
// Timeout très court pour que le GC puisse s'exécuter rapidement en test
mgr := correlation.NewManager(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
mgr.StartGC(ctx)
key := correlation.SessionKey{SrcIP: [4]byte{172, 16, 0, 1}, SrcPort: 9999}
mgr.GetOrCreate(key)
// Attendre que la session expire (timeout 50ms + marge)
time.Sleep(300 * time.Millisecond)
cancel()
// La session doit avoir été envoyée dans ReadyCh ou déjà expirée
// Ce test vérifie simplement l'absence de deadlock et de panic
select {
case <-mgr.ReadyCh:
t.Log("session reçue dans ReadyCh — normal")
default:
t.Log("ReadyCh vide (session GC'd avant lecture) — acceptable")
}
}
func TestManagerConcurrentAccess(t *testing.T) {
// Test de charge concurrent : 100 goroutines écrivent simultanément
mgr := correlation.NewManager(500 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
mgr.StartGC(ctx)
defer func() {
cancel()
mgr.Close()
}()
done := make(chan struct{})
for i := 0; i < 100; i++ {
go func(i int) {
key := correlation.SessionKey{
SrcIP: [4]byte{10, 0, byte(i >> 8), byte(i)},
SrcPort: uint16(i + 1024),
}
mgr.Update(key, func(s *correlation.SessionState) {
s.L3L4 = &correlation.L3L4{TTL: 64}
})
done <- struct{}{}
}(i)
}
for i := 0; i < 100; i++ {
<-done
}
}

View File

@ -0,0 +1,156 @@
package correlation
import (
"context"
"sync"
"time"
)
// numShards est le nombre de partitions de la carte de sessions.
// Doit être une puissance de 2 pour permettre le masquage bitwise.
const numShards = 256
// shard est une partition thread-safe de la carte de sessions.
type shard struct {
mu sync.RWMutex
sessions map[SessionKey]*SessionState
}
// Manager gère le cycle de vie des sessions TCP avec partitionnement
// pour réduire la contention lors des accès concurrents.
type Manager struct {
shards [numShards]shard
timeout time.Duration // délai d'expiration des sessions (500ms par défaut)
done chan struct{} // signal d'arrêt pour la goroutine GC
// ReadyCh reçoit les sessions expirées prêtes à être écrites dans ClickHouse.
ReadyCh chan *SessionState
}
// NewManager crée un Manager avec le délai d'expiration spécifié.
// Lance immédiatement les partitions de sessions.
func NewManager(timeout time.Duration) *Manager {
m := &Manager{
timeout: timeout,
done: make(chan struct{}),
ReadyCh: make(chan *SessionState, 4096),
}
// Initialiser chaque shard
for i := range m.shards {
m.shards[i].sessions = make(map[SessionKey]*SessionState)
}
return m
}
// getShard retourne le shard correspondant à la clé de session.
// L'index est calculé par XOR des octets de l'IP et du port.
func (m *Manager) getShard(key SessionKey) *shard {
idx := (key.SrcIP[3] ^ uint8(key.SrcPort>>8) ^ uint8(key.SrcPort)) & 0xFF
return &m.shards[idx]
}
// GetOrCreate retourne la session existante ou en crée une nouvelle
// pour la clé donnée. Thread-safe.
func (m *Manager) GetOrCreate(key SessionKey) *SessionState {
sh := m.getShard(key)
// Essai rapide en lecture seule
sh.mu.RLock()
s, ok := sh.sessions[key]
sh.mu.RUnlock()
if ok {
return s
}
// Création sous verrou exclusif
sh.mu.Lock()
defer sh.mu.Unlock()
// Double-check après acquisition du verrou exclusif
if s, ok = sh.sessions[key]; ok {
return s
}
now := time.Now()
s = &SessionState{
Key: key,
FirstSeen: now,
LastActivity: now,
Correlated: false,
MaxKeepAlives: 1,
}
sh.sessions[key] = s
return s
}
// Update applique la fonction fn sur la session identifiée par key,
// en créant la session si elle n'existe pas encore.
func (m *Manager) Update(key SessionKey, fn func(*SessionState)) {
s := m.GetOrCreate(key)
s.mu.Lock()
defer s.mu.Unlock()
fn(s)
s.LastActivity = time.Now()
}
// StartGC lance la goroutine de nettoyage des sessions expirées.
// Toutes les 100ms, elle parcourt tous les shards et exporte
// les sessions expirées ou détectées comme Slowloris vers ReadyCh.
func (m *Manager) StartGC(ctx context.Context) {
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-m.done:
return
case <-ticker.C:
m.gcRound(10 * time.Second)
}
}
}()
}
// gcRound effectue un passage de nettoyage sur tous les shards.
// Les sessions expirées ou Slowloris sont envoyées vers ReadyCh.
func (m *Manager) gcRound(slowlorisThreshold time.Duration) {
for i := range m.shards {
sh := &m.shards[i]
// Collecter les clés à supprimer sans bloquer les écritures
sh.mu.Lock()
var toDelete []SessionKey
for key, s := range sh.sessions {
expired := s.IsExpired(m.timeout)
slowloris := s.IsSlowloris(slowlorisThreshold)
if expired || slowloris {
// Marquer les sessions Slowloris comme non corrélées
if slowloris && !expired {
s.mu.Lock()
s.Correlated = false
s.mu.Unlock()
}
toDelete = append(toDelete, key)
// Envoyer sans bloquer (drop si le canal est plein)
select {
case m.ReadyCh <- s:
default:
}
}
}
for _, k := range toDelete {
delete(sh.sessions, k)
}
sh.mu.Unlock()
}
}
// Close arrête la goroutine GC et ferme le canal ReadyCh.
func (m *Manager) Close() {
close(m.done)
close(m.ReadyCh)
}

View File

@ -0,0 +1,110 @@
// Package correlation gère l'état des sessions TCP et leur corrélation
// entre les couches réseau (L3/L4), TLS (L5) et applicative (L7).
package correlation
import (
"sync"
"time"
)
// SessionKey identifie une connexion TCP de façon unique.
type SessionKey struct {
SrcIP [4]byte // adresse IP source en représentation binaire
SrcPort uint16 // port source
}
// L3L4 contient les caractéristiques réseau et transport de la connexion.
type L3L4 struct {
TTL uint8 // TTL IP observé dans le SYN
DFBit bool // bit Don't Fragment actif
IPID uint16 // champ identification IP
WindowSize uint16 // taille de fenêtre TCP initiale
WindowScale uint8 // facteur d'échelle de fenêtre (0xFF = absent)
MSS uint16 // Maximum Segment Size (0 = absent)
TCPOptionsRaw []byte // options TCP brutes (max 40 octets)
SYNTimestamp time.Time // horodatage du paquet SYN
}
// TLSInfo contient les données extraites du ClientHello TLS.
type TLSInfo struct {
ClientHelloRaw []byte // payload ClientHello brut
JA4Hash string // empreinte JA4 calculée
SNI string // Server Name Indication
ALPN []string // protocoles Application-Layer Protocol Negotiation
TLSVersion uint16 // version TLS la plus haute annoncée
CipherSuites []uint16 // suites de chiffrement proposées
Extensions []uint16 // identifiants des extensions TLS
Timestamp time.Time // horodatage du ClientHello
}
// HTTP2Settings contient les paramètres SETTINGS et WINDOW_UPDATE du client HTTP/2.
type HTTP2Settings struct {
HeaderTableSize int32 // SETTINGS_HEADER_TABLE_SIZE (-1 si absent)
EnablePush int32 // SETTINGS_ENABLE_PUSH
MaxConcurrentStreams int32 // SETTINGS_MAX_CONCURRENT_STREAMS
InitialWindowSize int32 // SETTINGS_INITIAL_WINDOW_SIZE
MaxFrameSize int32 // SETTINGS_MAX_FRAME_SIZE
MaxHeaderListSize int32 // SETTINGS_MAX_HEADER_LIST_SIZE
UnknownSettings int32 // paramètre 0x7 (JA4H2)
WindowUpdateIncrement uint32 // valeur WINDOW_UPDATE sur stream 0
PseudoHeaderOrder []string // ordre des pseudo-headers [:method, :authority, ...]
}
// HTTPRequest représente une requête HTTP observée dans la session.
type HTTPRequest struct {
Method string // méthode HTTP (GET, POST, etc.)
Path string // chemin de la requête
QueryString string // paramètres de requête
StatusCode int // code de statut de la réponse
ResponseSize int64 // taille de la réponse en octets
DurationMS float64 // durée de traitement en millisecondes
HeaderOrder []string // ordre exact des en-têtes HTTP bruts
HeaderOrderSig string // signature de l'ordre des en-têtes (hash)
HTTP2Settings *HTTP2Settings // non nil uniquement pour HTTP/2
Timestamp time.Time // horodatage de la requête
}
// SessionState représente l'état complet d'une connexion TCP corrélée.
// La structure est thread-safe via un mutex interne.
type SessionState struct {
Key SessionKey // identifiant de la session
L3L4 *L3L4 // données réseau/transport (peut être nil si L7-only)
TLS *TLSInfo // données TLS (peut être nil si HTTP plain)
Requests []HTTPRequest // requêtes HTTP observées
MaxKeepAlives int // nombre maximum de requêtes keep-alive
FirstSeen time.Time // horodatage de création de la session
LastActivity time.Time // horodatage de la dernière activité
Correlated bool // true si L3/L4 et L7 sont corrélés
mu sync.Mutex // protection des modifications concurrentes
}
// IsExpired indique si la session n'a reçu aucune activité depuis timeout.
func (s *SessionState) IsExpired(timeout time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
return time.Since(s.LastActivity) > timeout
}
// AddRequest ajoute une requête HTTP à la session et met à jour LastActivity.
func (s *SessionState) AddRequest(req HTTPRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.Requests = append(s.Requests, req)
s.LastActivity = time.Now()
if len(s.Requests) > s.MaxKeepAlives && s.MaxKeepAlives > 0 {
s.MaxKeepAlives = len(s.Requests)
}
}
// IsSlowloris détecte si la session présente un profil d'attaque Slowloris :
// première activité il y a plus de threshold sans aucune requête complète.
func (s *SessionState) IsSlowloris(threshold time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.Requests) > 0 {
return false
}
return time.Since(s.FirstSeen) > threshold
}