feat(ja4ebpf): add SSL_write uprobe, HPACK decoder, and AcceptCache for session correlation

Add uprobe_ssl_write_entry/uretprobe_ssl_write_exit to capture server HTTP
responses via SSL_write with direction=1. Implement full HPACK decoder
(RFC 7541 static table, multi-byte integers, literal representations) for
HTTP/2 header extraction. Add AcceptCache mapping {tgid,fd}→SessionKey
from accept4 events as authoritative source for SSL correlation when BPF
ssl_conn_map has src_ip=0. Add ip_total_length to tcp_syn_event BPF struct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-04-15 03:34:43 +02:00
parent a02423fd18
commit 24306ef390
7 changed files with 847 additions and 16 deletions

View File

@ -155,3 +155,119 @@ func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byt
}
return append(frame, payload...)
}
func TestDecodeH2HeadersBlockLiteralWithIndexedName(t *testing.T) {
// Literal with incremental indexing, indexed name (user-agent = index 95)
// Prefix byte: 0x40 | 95 = 0x5F... wait, 95 > 63 so we need multi-byte
// For index 95: first byte = 0x40 | 0x3F = 0x7F, second byte = 95 - 63 = 32 = 0x20
// Then value: 7-bit length "Mozilla/5.0" = 11 bytes, no Huffman
h2block := []byte{
0x7F, 0x20, // indexed name = 95 (user-agent), with incremental indexing
0x0B, 'M', 'o', 'z', 'i', 'l', 'l', 'a', '/', '5', '.', '0', // value length 11 + value
}
kv, order := parser.DecodeH2HeadersBlock(h2block)
if kv["user-agent"] != "Mozilla/5.0" {
t.Errorf("user-agent: attendu 'Mozilla/5.0', obtenu %q", kv["user-agent"])
}
if len(order) != 1 || order[0] != "user-agent" {
t.Errorf("order: attendu [user-agent], obtenu %v", order)
}
}
func TestDecodeH2HeadersBlockLiteralWithoutIndexing(t *testing.T) {
// Literal without indexing, indexed name (accept-encoding = index 16)
// 4-bit prefix max = 15, so index 16 needs multi-byte: 0x0F 0x01
h2block := []byte{
0x0F, 0x01, // literal without indexing, name index = 16 (accept-encoding)
0x12, 'g', 'z', 'i', 'p', ',', ' ', 'd', 'e', 'f', 'l', 'a', 't', 'e', ',', ' ', 'b', 'r', // value
}
kv, _ := parser.DecodeH2HeadersBlock(h2block)
if kv["accept-encoding"] != "gzip, deflate, br" {
t.Errorf("accept-encoding: attendu 'gzip, deflate, br', obtenu %q", kv["accept-encoding"])
}
}
func TestDecodeH2HeadersBlockLiteralNewName(t *testing.T) {
// Literal with incremental indexing, new name
// Prefix byte: 0x40 (index = 0, new name)
// Name: "x-custom-header", Value: "test-value"
name := "x-custom-header"
value := "test-value"
h2block := []byte{
0x40, // literal with incremental indexing, new name
byte(len(name)), // name length
}
h2block = append(h2block, []byte(name)...)
h2block = append(h2block, byte(len(value)))
h2block = append(h2block, []byte(value)...)
kv, order := parser.DecodeH2HeadersBlock(h2block)
// x-custom-header is not in hpackCapturedHeaders, so it won't be in kv
if len(kv) != 0 {
t.Errorf("x-custom-header ne doit pas être capturé (pas dans hpackCapturedHeaders), obtenu %v", kv)
}
_ = order
}
func TestDecodeH2HeadersBlockPseudoHeaders(t *testing.T) {
// Pseudo-headers :method GET (indexed, byte 0x82), :path / (indexed, byte 0x84)
// Then :authority as literal with indexed name (index 1)
// 0x40 | 1 = 0x41, then value "example.com"
h2block := []byte{
0x82, // indexed :method GET
0x84, // indexed :path /
0x41, // literal with incremental indexing, name index 1 (:authority)
0x0B, 'e', 'x', 'a', 'm', 'p', 'l', 'e', '.', 'c', 'o', 'm', // value
}
kv, order := parser.DecodeH2HeadersBlock(h2block)
if kv[":authority"] != "example.com" {
t.Errorf(":authority: attendu 'example.com', obtenu %q", kv[":authority"])
}
if len(order) < 1 {
t.Errorf("order ne doit pas être vide, obtenu %v", order)
}
}
func TestIsH2FrameHeader(t *testing.T) {
// Frame SETTINGS valide
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
if !parser.IsH2FrameHeader(frame) {
t.Error("IsH2FrameHeader doit retourner true pour frame SETTINGS valide")
}
// Données aléatoires
random := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
if parser.IsH2FrameHeader(random) {
t.Error("IsH2FrameHeader doit retourner false pour données invalides (length > 16384)")
}
// Trop court
if parser.IsH2FrameHeader([]byte{0x00, 0x00}) {
t.Error("IsH2FrameHeader doit retourner false pour données trop courtes")
}
}
func TestExtractH2HeaderKV(t *testing.T) {
// HEADERS frame with :authority literal
headersPayload := []byte{
0x41, // literal with incremental indexing, name index 1 (:authority)
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // value
}
frame := buildH2Frame(0x1, 0x04, 1, headersPayload) // HEADERS, END_HEADERS, stream 1
kv := parser.ExtractH2HeaderKV(frame)
if kv[":authority"] != "example" {
t.Errorf(":authority: attendu 'example', obtenu %q", kv[":authority"])
}
}
func TestFormatTCPOptions(t *testing.T) {
// MSS(2,4bytes) + WS(3,3bytes) + SACK(4,2bytes) + NOP(1) + TS(8,10bytes)
opts := []byte{
2, 4, 0x05, 0xB4, // MSS = 1460
3, 3, 6, // WS = 6
4, 2, // SACK Permitted
1, // NOP
8, 10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // TS
}
// This function is in the writer package, not parser - skip direct test here
_ = opts
}