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:
206
services/ja4ebpf/internal/correlation/correlation_test.go
Normal file
206
services/ja4ebpf/internal/correlation/correlation_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
156
services/ja4ebpf/internal/correlation/manager.go
Normal file
156
services/ja4ebpf/internal/correlation/manager.go
Normal 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)
|
||||
}
|
||||
110
services/ja4ebpf/internal/correlation/session.go
Normal file
110
services/ja4ebpf/internal/correlation/session.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user