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,157 @@
package parser_test
import (
"testing"
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
)
func TestDetectH2PrefaceTrue(t *testing.T) {
preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
data := append(preface, 0x00, 0x00) // données supplémentaires
if !parser.DetectH2Preface(data) {
t.Error("H2Magic non détecté dans un buffer valide")
}
}
func TestDetectH2PrefaceFalse(t *testing.T) {
if parser.DetectH2Preface([]byte("GET / HTTP/1.1\r\n")) {
t.Error("détection faux positif HTTP/1.1 comme HTTP/2")
}
}
func TestDetectH2PrefaceTooShort(t *testing.T) {
if parser.DetectH2Preface([]byte("PRI *")) {
t.Error("détection sur buffer trop court")
}
}
func TestH2MagicPrefaceLen(t *testing.T) {
if parser.H2MagicPrefaceLen() != 24 {
t.Errorf("longueur préambule HTTP/2 attendue 24, obtenue %d", parser.H2MagicPrefaceLen())
}
}
func TestParseH2ClientPrefaceSettingsEmpty(t *testing.T) {
// Frame SETTINGS vide (longueur 0, aucun paramètre) sur stream 0
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
settings, err := parser.ParseH2ClientPreface(frame)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings == nil {
t.Fatal("settings ne doit pas être nil")
}
// Tous les champs doivent être -1 (absent)
if settings.HeaderTableSize != -1 {
t.Errorf("HeaderTableSize: attendu -1, obtenu %d", settings.HeaderTableSize)
}
if settings.InitialWindowSize != -1 {
t.Errorf("InitialWindowSize: attendu -1, obtenu %d", settings.InitialWindowSize)
}
}
func TestParseH2ClientPrefaceSettingsWithValues(t *testing.T) {
// Frame SETTINGS avec INITIAL_WINDOW_SIZE=65536 et MAX_CONCURRENT_STREAMS=100
settingsPayload := []byte{
0x00, 0x04, 0x00, 0x01, 0x00, 0x00, // INITIAL_WINDOW_SIZE = 65536
0x00, 0x03, 0x00, 0x00, 0x00, 0x64, // MAX_CONCURRENT_STREAMS = 100
}
frame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
settings, err := parser.ParseH2ClientPreface(frame)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings.InitialWindowSize != 65536 {
t.Errorf("InitialWindowSize: attendu 65536, obtenu %d", settings.InitialWindowSize)
}
if settings.MaxConcurrentStreams != 100 {
t.Errorf("MaxConcurrentStreams: attendu 100, obtenu %d", settings.MaxConcurrentStreams)
}
// Les paramètres non présents restent à -1
if settings.HeaderTableSize != -1 {
t.Errorf("HeaderTableSize non fourni: attendu -1, obtenu %d", settings.HeaderTableSize)
}
}
func TestParseH2ClientPrefaceWindowUpdate(t *testing.T) {
// Frame WINDOW_UPDATE sur stream 0 avec incrément = 1073741824
wuPayload := []byte{0x40, 0x00, 0x00, 0x00} // 0x40000000 = 1073741824
frame := buildH2Frame(0x8, 0x0, 0, wuPayload)
settings, err := parser.ParseH2ClientPreface(frame)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings.WindowUpdateIncrement != 1073741824 {
t.Errorf("WindowUpdateIncrement: attendu 1073741824, obtenu %d", settings.WindowUpdateIncrement)
}
}
func TestParseH2ClientPrefaceCombined(t *testing.T) {
// SETTINGS + WINDOW_UPDATE combinés (comme envoyé par curl/h2)
settingsPayload := []byte{
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
0x00, 0x04, 0x00, 0x00, 0xff, 0xff, // INITIAL_WINDOW_SIZE = 65535
}
wuPayload := []byte{0x00, 0x0f, 0x00, 0x01} // WINDOW_UPDATE incr = 983041
frames := buildH2Frame(0x4, 0x0, 0, settingsPayload)
frames = append(frames, buildH2Frame(0x8, 0x0, 0, wuPayload)...)
settings, err := parser.ParseH2ClientPreface(frames)
if err != nil {
t.Fatalf("ParseH2ClientPreface: %v", err)
}
if settings.HeaderTableSize != 4096 {
t.Errorf("HeaderTableSize: attendu 4096, obtenu %d", settings.HeaderTableSize)
}
if settings.InitialWindowSize != 65535 {
t.Errorf("InitialWindowSize: attendu 65535, obtenu %d", settings.InitialWindowSize)
}
if settings.WindowUpdateIncrement != 983041 {
t.Errorf("WindowUpdateIncrement: attendu 983041, obtenu %d", settings.WindowUpdateIncrement)
}
}
func TestParseH2ClientPrefaceEmpty(t *testing.T) {
// Données vides : doit retourner sans erreur, settings avec valeurs par défaut (-1)
settings, err := parser.ParseH2ClientPreface([]byte{})
if err != nil {
t.Fatalf("ParseH2ClientPreface sur vide: %v", err)
}
if settings == nil {
t.Error("settings ne doit pas être nil même pour données vides")
}
if settings.HeaderTableSize != -1 {
t.Errorf("HeaderTableSize: attendu -1 par défaut, obtenu %d", settings.HeaderTableSize)
}
}
func TestParseH2ClientPrefaceTruncatedFrame(t *testing.T) {
// Frame tronquée : en-tête complet mais payload incomplet
truncated := []byte{0x00, 0x00, 0x06, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01} // payload tronqué
settings, err := parser.ParseH2ClientPreface(truncated)
if err != nil {
t.Fatalf("ParseH2ClientPreface sur frame tronquée: %v (doit tolérer)", err)
}
// Les paramètres restent à -1 car le payload est incomplet
_ = settings
}
// ── Helpers ───────────────────────────────────────────────────────────────
// buildH2Frame construit une frame HTTP/2 brute (en-tête 9 octets + payload).
func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byte {
l := len(payload)
frame := []byte{
byte(l >> 16), byte(l >> 8), byte(l), // longueur sur 3 octets
frameType, flags,
byte(streamID >> 24), byte(streamID >> 16), byte(streamID >> 8), byte(streamID),
}
return append(frame, payload...)
}