diff --git a/cmd/ja4sentinel/main.go b/cmd/ja4sentinel/main.go index 8002143..56eab49 100644 --- a/cmd/ja4sentinel/main.go +++ b/cmd/ja4sentinel/main.go @@ -23,7 +23,7 @@ import ( var ( // Version information (set via ldflags) - Version = "1.1.11" + Version = "1.1.15" BuildTime = "unknown" GitCommit = "unknown" ) diff --git a/internal/capture/capture.go b/internal/capture/capture.go index 9b57759..7218d7f 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -271,16 +271,17 @@ func extractIP(addr net.Addr) net.IP { } // buildBPFFilter builds a BPF filter for the specified ports and local IPs -// Filter: (tcp port 443 or tcp port 8443) and (dst host 192.168.1.10 or dst host 10.0.0.5) +// Filter: (tcp dst port 443 or tcp dst port 8443) and (dst host 192.168.1.10 or dst host 10.0.0.5) +// Uses "tcp dst port" to only capture client→server traffic (not server→client responses) func (c *CaptureImpl) buildBPFFilter(ports []uint16, localIPs []string) string { if len(ports) == 0 { return "tcp" } - // Build port filter + // Build port filter (dst port only to avoid capturing server responses) portParts := make([]string, len(ports)) for i, port := range ports { - portParts[i] = fmt.Sprintf("tcp port %d", port) + portParts[i] = fmt.Sprintf("tcp dst port %d", port) } portFilter := "(" + strings.Join(portParts, ") or (") + ")" diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go index 5aaa1e9..4b6b3fd 100644 --- a/internal/capture/capture_test.go +++ b/internal/capture/capture_test.go @@ -525,25 +525,25 @@ func TestCaptureImpl_buildBPFFilter(t *testing.T) { name: "single port no IPs", ports: []uint16{443}, localIPs: []string{}, - wantParts: []string{"tcp port 443"}, + wantParts: []string{"tcp dst port 443"}, }, { name: "single port with single IP", ports: []uint16{443}, localIPs: []string{"192.168.1.10"}, - wantParts: []string{"tcp port 443", "dst host 192.168.1.10"}, + wantParts: []string{"tcp dst port 443", "dst host 192.168.1.10"}, }, { name: "multiple ports with multiple IPs", ports: []uint16{443, 8443}, localIPs: []string{"192.168.1.10", "10.0.0.5"}, - wantParts: []string{"tcp port 443", "tcp port 8443", "dst host 192.168.1.10", "dst host 10.0.0.5"}, + wantParts: []string{"tcp dst port 443", "tcp dst port 8443", "dst host 192.168.1.10", "dst host 10.0.0.5"}, }, { name: "IPv6 address", ports: []uint16{443}, localIPs: []string{"2001:db8::1"}, - wantParts: []string{"tcp port 443", "dst host 2001:db8::1"}, + wantParts: []string{"tcp dst port 443", "dst host 2001:db8::1"}, }, } diff --git a/internal/fingerprint/engine.go b/internal/fingerprint/engine.go index ca4f9df..b65f45e 100644 --- a/internal/fingerprint/engine.go +++ b/internal/fingerprint/engine.go @@ -2,6 +2,7 @@ package fingerprint import ( + "encoding/binary" "fmt" "ja4sentinel/api" @@ -29,8 +30,14 @@ func (e *EngineImpl) FromClientHello(ch api.TLSClientHello) (*api.Fingerprints, // Parse the ClientHello using tlsfingerprint fp, err := tlsfingerprint.ParseClientHello(ch.Payload) if err != nil { - return nil, fmt.Errorf("failed to parse ClientHello from %s:%d -> %s:%d (conn_id=%s, payload_len=%d): %w", - ch.SrcIP, ch.SrcPort, ch.DstIP, ch.DstPort, ch.ConnID, len(ch.Payload), err) + // Try to sanitize truncated extensions and retry + if sanitized := sanitizeClientHelloExtensions(ch.Payload); sanitized != nil { + fp, err = tlsfingerprint.ParseClientHello(sanitized) + } + if err != nil { + return nil, fmt.Errorf("failed to parse ClientHello from %s:%d -> %s:%d (conn_id=%s, payload_len=%d): %w", + ch.SrcIP, ch.SrcPort, ch.DstIP, ch.DstPort, ch.ConnID, len(ch.Payload), err) + } } // Generate JA4 fingerprint @@ -67,3 +74,93 @@ func extractJA4Hash(ja4 string) string { } return "" } + +// sanitizeClientHelloExtensions fixes ClientHellos with truncated extension data +// by adjusting the extensions length to include only complete extensions. +// Returns a corrected copy, or nil if the payload cannot be fixed. +func sanitizeClientHelloExtensions(data []byte) []byte { + if len(data) < 5 || data[0] != 0x16 { + return nil + } + recordLen := int(data[3])<<8 | int(data[4]) + if len(data) < 5+recordLen { + return nil + } + payload := data[5 : 5+recordLen] + if len(payload) < 4 || payload[0] != 0x01 { + return nil + } + helloLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3]) + if len(payload) < 4+helloLen { + return nil + } + hello := payload[4 : 4+helloLen] + + // Skip through ClientHello fields to reach extensions + offset := 2 + 32 // version + random + if len(hello) < offset+1 { + return nil + } + offset += 1 + int(hello[offset]) // session ID + if len(hello) < offset+2 { + return nil + } + csLen := int(hello[offset])<<8 | int(hello[offset+1]) + offset += 2 + csLen // cipher suites + if len(hello) < offset+1 { + return nil + } + offset += 1 + int(hello[offset]) // compression methods + if len(hello) < offset+2 { + return nil + } + extLenOffset := offset // position of extensions length field + declaredExtLen := int(hello[offset])<<8 | int(hello[offset+1]) + offset += 2 + extStart := offset + + if len(hello) < extStart+declaredExtLen { + return nil + } + extData := hello[extStart : extStart+declaredExtLen] + + // Walk extensions, find how many complete ones exist + validLen := 0 + pos := 0 + for pos < len(extData) { + if pos+4 > len(extData) { + break + } + extBodyLen := int(extData[pos+2])<<8 | int(extData[pos+3]) + if pos+4+extBodyLen > len(extData) { + break // this extension is truncated + } + pos += 4 + extBodyLen + validLen = pos + } + + if validLen == declaredExtLen { + return nil // no truncation found, nothing to fix + } + + // Build a corrected copy with adjusted extensions length + fixed := make([]byte, len(data)) + copy(fixed, data) + + // Absolute offset of extensions length field within data + extLenAbs := 5 + 4 + extLenOffset + diff := declaredExtLen - validLen + + // Update extensions length + binary.BigEndian.PutUint16(fixed[extLenAbs:], uint16(validLen)) + // Update ClientHello handshake length + newHelloLen := helloLen - diff + fixed[5+1] = byte(newHelloLen >> 16) + fixed[5+2] = byte(newHelloLen >> 8) + fixed[5+3] = byte(newHelloLen) + // Update TLS record length + newRecordLen := recordLen - diff + binary.BigEndian.PutUint16(fixed[3:5], uint16(newRecordLen)) + + return fixed[:5+newRecordLen] +} diff --git a/internal/fingerprint/engine_test.go b/internal/fingerprint/engine_test.go index 23d9be8..4071d46 100644 --- a/internal/fingerprint/engine_test.go +++ b/internal/fingerprint/engine_test.go @@ -5,6 +5,8 @@ import ( "testing" "ja4sentinel/api" + + tlsfingerprint "github.com/psanford/tlsfingerprint" ) func TestFromClientHello(t *testing.T) { @@ -253,3 +255,132 @@ func TestFromClientHello_EmptyJA4Hash(t *testing.T) { // JA4Hash may be empty if the JA4 format doesn't include underscores // This is acceptable behavior } + +// buildClientHelloWithTruncatedExtension creates a ClientHello where the last +// extension declares more data than actually present. +func buildClientHelloWithTruncatedExtension() []byte { + // Build a valid SNI extension first + sniHostname := []byte("example.com") + sniExt := []byte{ + 0x00, 0x00, // Extension type: server_name + } + sniData := []byte{0x00} + sniListLen := 1 + 2 + len(sniHostname) // type(1) + len(2) + hostname + sniData = append(sniData, byte(sniListLen>>8), byte(sniListLen)) + sniData = append(sniData, 0x00) // hostname type + sniData = append(sniData, byte(len(sniHostname)>>8), byte(len(sniHostname))) + sniData = append(sniData, sniHostname...) + sniExt = append(sniExt, byte(len(sniData)>>8), byte(len(sniData))) + sniExt = append(sniExt, sniData...) + + // Build a truncated extension: declares 100 bytes but only has 5 + truncatedExt := []byte{ + 0x00, 0x15, // Extension type: padding + 0x00, 0x64, // Extension data length: 100 (but we only provide 5) + 0x00, 0x00, 0x00, 0x00, 0x00, // Only 5 bytes of padding + } + + // Extensions = valid SNI + truncated padding + extensions := append(sniExt, truncatedExt...) + // But the extensions length field claims the full size (including the bad extension) + extLen := len(extensions) + + // Cipher suites + cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f} + compressionMethods := []byte{0x01, 0x00} + + handshakeBody := []byte{0x03, 0x03} + for i := 0; i < 32; i++ { + handshakeBody = append(handshakeBody, 0x01) + } + handshakeBody = append(handshakeBody, 0x00) // session ID length: 0 + handshakeBody = append(handshakeBody, byte(len(cipherSuites)>>8), byte(len(cipherSuites))) + handshakeBody = append(handshakeBody, cipherSuites...) + handshakeBody = append(handshakeBody, compressionMethods...) + handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen)) + handshakeBody = append(handshakeBody, extensions...) + + handshakeLen := len(handshakeBody) + handshake := append([]byte{ + 0x01, + byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen), + }, handshakeBody...) + + recordLen := len(handshake) + record := make([]byte, 5+recordLen) + record[0] = 0x16 + record[1] = 0x03 + record[2] = 0x03 + record[3] = byte(recordLen >> 8) + record[4] = byte(recordLen) + copy(record[5:], handshake) + + return record +} + +func TestFromClientHello_TruncatedExtension_StillGeneratesFingerprint(t *testing.T) { + payload := buildClientHelloWithTruncatedExtension() + + ch := api.TLSClientHello{ + SrcIP: "4.251.36.192", + SrcPort: 19346, + DstIP: "212.95.72.88", + DstPort: 443, + Payload: payload, + ConnID: "4.251.36.192:19346->212.95.72.88:443", + } + + engine := NewEngine() + fp, err := engine.FromClientHello(ch) + + if err != nil { + t.Fatalf("FromClientHello() should succeed after sanitization, got error: %v", err) + } + if fp == nil { + t.Fatal("FromClientHello() returned nil fingerprint") + } + if fp.JA4 == "" { + t.Error("JA4 should be populated even with truncated extension") + } + if fp.JA3 == "" { + t.Error("JA3 should be populated even with truncated extension") + } +} + +func TestSanitizeClientHelloExtensions(t *testing.T) { + t.Run("valid payload returns nil", func(t *testing.T) { + valid := buildMinimalClientHelloForTest() + result := sanitizeClientHelloExtensions(valid) + if result != nil { + t.Error("should return nil for valid payload (no fix needed)") + } + }) + + t.Run("truncated extension is fixed", func(t *testing.T) { + truncated := buildClientHelloWithTruncatedExtension() + result := sanitizeClientHelloExtensions(truncated) + if result == nil { + t.Fatal("should return sanitized payload") + } + // The sanitized payload should be parseable by the library + fp, err := tlsfingerprint.ParseClientHello(result) + if err != nil { + t.Fatalf("sanitized payload should parse without error, got: %v", err) + } + if fp == nil { + t.Fatal("sanitized payload should produce a fingerprint") + } + }) + + t.Run("too short returns nil", func(t *testing.T) { + if sanitizeClientHelloExtensions([]byte{0x16}) != nil { + t.Error("should return nil for short payload") + } + }) + + t.Run("non-TLS returns nil", func(t *testing.T) { + if sanitizeClientHelloExtensions([]byte{0x15, 0x03, 0x03, 0x00, 0x01, 0x00}) != nil { + t.Error("should return nil for non-TLS payload") + } + }) +} diff --git a/internal/tlsparse/parser.go b/internal/tlsparse/parser.go index 00acb89..9cb7bfe 100644 --- a/internal/tlsparse/parser.go +++ b/internal/tlsparse/parser.go @@ -53,6 +53,8 @@ type ConnectionFlow struct { IPMeta api.IPMeta TCPMeta api.TCPMeta HelloBuffer []byte + NextSeq uint32 // Expected next TCP sequence number for reassembly + SeqInit bool // Whether NextSeq has been initialized } // ParserImpl implements the api.Parser interface for TLS parsing @@ -264,25 +266,39 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { return nil, nil // Source IP is excluded } + key := flowKey(srcIP, srcPort, dstIP, dstPort) + + // Handle SYN packets: create flow and store IP/TCP metadata from SYN + // SYN is the only packet that carries TCP options (MSS, WindowScale, SACK, etc.) + if tcp.SYN && !tcp.ACK { + flow := p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta) + if flow != nil { + flow.mu.Lock() + // SYN consumes 1 sequence number, so data starts at Seq+1 + flow.NextSeq = tcp.Seq + 1 + flow.SeqInit = true + flow.mu.Unlock() + } + return nil, nil + } + // Get TCP payload (TLS data) payload := tcp.Payload if len(payload) == 0 { - return nil, nil // No payload + return nil, nil // No payload (ACK, FIN, etc.) } - key := flowKey(srcIP, srcPort, dstIP, dstPort) - // Check if flow exists before acquiring write lock p.mu.RLock() - flow, flowExists := p.flows[key] + _, flowExists := p.flows[key] p.mu.RUnlock() - // Early exit for non-ClientHello first packet + // Early exit for non-ClientHello first packet (no SYN seen, no TLS handshake) if !flowExists && payload[0] != 22 { return nil, nil } - flow = p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta) + flow := p.getOrCreateFlow(key, srcIP, srcPort, dstIP, dstPort, ipMeta, tcpMeta) if flow == nil { return nil, nil } @@ -296,6 +312,26 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { return nil, nil // Already processed this flow } + // TCP sequence tracking: detect retransmissions and maintain order + seq := tcp.Seq + if flow.SeqInit { + if seq < flow.NextSeq { + // Retransmission — skip duplicate data + return nil, nil + } + if seq > flow.NextSeq && flow.State == WAIT_CLIENT_HELLO { + // Gap detected — missing segment, drop this flow + p.mu.Lock() + delete(p.flows, key) + p.mu.Unlock() + return nil, nil + } + } + + // Update expected next sequence number + flow.NextSeq = seq + uint32(len(payload)) + flow.SeqInit = true + // Check if this is a TLS ClientHello clientHello, err := parseClientHello(payload) if err != nil { @@ -313,14 +349,15 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { // Generate ConnID from flow key connID := key + // Use flow metadata (captured from SYN) for accurate IP/TCP fingerprinting ch := &api.TLSClientHello{ SrcIP: srcIP, SrcPort: srcPort, DstIP: dstIP, DstPort: dstPort, Payload: clientHello, - IPMeta: ipMeta, - TCPMeta: tcpMeta, + IPMeta: flow.IPMeta, + TCPMeta: flow.TCPMeta, ConnID: connID, SNI: extInfo.SNI, ALPN: joinStringSlice(extInfo.ALPN, ","), @@ -366,14 +403,15 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { // Generate ConnID from flow key connID := key + // Use flow metadata (captured from SYN) for accurate IP/TCP fingerprinting ch := &api.TLSClientHello{ SrcIP: srcIP, SrcPort: srcPort, DstIP: dstIP, DstPort: dstPort, Payload: clientHello, - IPMeta: ipMeta, - TCPMeta: tcpMeta, + IPMeta: flow.IPMeta, + TCPMeta: flow.TCPMeta, ConnID: connID, SNI: extInfo.SNI, ALPN: joinStringSlice(extInfo.ALPN, ","), @@ -587,6 +625,12 @@ func extractTLSExtensions(payload []byte) (*TLSExtensionInfo, error) { // Use tlsfingerprint to parse ALPN and TLS version fp, err := tlsfingerprint.ParseClientHello(payload) + if err != nil { + // Retry with sanitized payload (handles truncated/malformed extensions) + if sanitized := sanitizeTLSRecord(payload); sanitized != nil { + fp, err = tlsfingerprint.ParseClientHello(sanitized) + } + } if err == nil && fp != nil { // Extract ALPN protocols if len(fp.ALPNProtocols) > 0 { @@ -757,3 +801,84 @@ func joinStringSlice(slice []string, sep string) string { } return strings.Join(slice, sep) } + +// sanitizeTLSRecord attempts to fix a TLS ClientHello with truncated extensions +// by adjusting lengths to cover only complete extensions. Returns a corrected +// copy of the record, or nil if no fix is needed or possible. +func sanitizeTLSRecord(data []byte) []byte { + if len(data) < 5 || data[0] != 0x16 { + return nil + } + recordLen := int(data[3])<<8 | int(data[4]) + if len(data) < 5+recordLen { + return nil + } + payload := data[5 : 5+recordLen] + if len(payload) < 4 || payload[0] != 0x01 { + return nil + } + helloLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3]) + if len(payload) < 4+helloLen { + return nil + } + hello := payload[4 : 4+helloLen] + + // Skip version + random + session ID + cipher suites + compression methods + offset := 2 + 32 + if len(hello) < offset+1 { + return nil + } + offset += 1 + int(hello[offset]) // session ID + if len(hello) < offset+2 { + return nil + } + csLen := int(hello[offset])<<8 | int(hello[offset+1]) + offset += 2 + csLen + if len(hello) < offset+1 { + return nil + } + offset += 1 + int(hello[offset]) // compression methods + if len(hello) < offset+2 { + return nil + } + extLenOffset := offset + declaredExtLen := int(hello[offset])<<8 | int(hello[offset+1]) + offset += 2 + extStart := offset + if len(hello) < extStart+declaredExtLen { + return nil + } + extData := hello[extStart : extStart+declaredExtLen] + + // Walk extensions to find the last complete one + validLen := 0 + pos := 0 + for pos < len(extData) { + if pos+4 > len(extData) { + break + } + extBodyLen := int(extData[pos+2])<<8 | int(extData[pos+3]) + if pos+4+extBodyLen > len(extData) { + break // truncated extension + } + pos += 4 + extBodyLen + validLen = pos + } + + if validLen == declaredExtLen { + return nil // no truncation, nothing to fix + } + + fixed := make([]byte, len(data)) + copy(fixed, data) + diff := declaredExtLen - validLen + extLenAbs := 5 + 4 + extLenOffset + binary.BigEndian.PutUint16(fixed[extLenAbs:], uint16(validLen)) + newHelloLen := helloLen - diff + fixed[5+1] = byte(newHelloLen >> 16) + fixed[5+2] = byte(newHelloLen >> 8) + fixed[5+3] = byte(newHelloLen) + newRecordLen := recordLen - diff + binary.BigEndian.PutUint16(fixed[3:5], uint16(newRecordLen)) + return fixed[:5+newRecordLen] +} diff --git a/internal/tlsparse/parser_test.go b/internal/tlsparse/parser_test.go index c4c162c..a8f9d30 100644 --- a/internal/tlsparse/parser_test.go +++ b/internal/tlsparse/parser_test.go @@ -336,7 +336,7 @@ func TestProcess_DropsWhenHelloBufferExceedsLimit(t *testing.T) { // TLS-like payload, but intentionally incomplete to trigger accumulation. payloadChunk := []byte{0x16, 0x03, 0x03, 0x00, 0x20, 0x01} // len = 6 - pkt1 := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, payloadChunk) + pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, payloadChunk, 1) ch, err := parser.Process(pkt1) if err != nil { t.Fatalf("first Process() error = %v", err) @@ -354,7 +354,7 @@ func TestProcess_DropsWhenHelloBufferExceedsLimit(t *testing.T) { t.Fatal("flow should exist after first chunk") } - pkt2 := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, payloadChunk) + pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, payloadChunk, 1+uint32(len(payloadChunk))) ch, err = parser.Process(pkt2) if err != nil { t.Fatalf("second Process() error = %v", err) @@ -403,6 +403,10 @@ func TestProcess_NonTLSNewFlowNotTracked(t *testing.T) { } func buildRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, payload []byte) api.RawPacket { + return buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, payload, 1) +} + +func buildRawPacketWithSeq(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, payload []byte, seq uint32) api.RawPacket { t.Helper() ip := &layers.IPv4{ @@ -416,7 +420,7 @@ func buildRawPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, tcp := &layers.TCP{ SrcPort: layers.TCPPort(srcPort), DstPort: layers.TCPPort(dstPort), - Seq: 1, + Seq: seq, ACK: true, Window: 65535, } @@ -1307,3 +1311,482 @@ func TestParser_SLLPacketType(t *testing.T) { t.Fatal("Process() should return TLSClientHello for PACKET_HOST") } } + +// buildSYNPacket creates a raw SYN packet (no payload) with TCP options +func buildSYNPacket(t *testing.T, srcIP, dstIP string, srcPort, dstPort uint16, mss uint16, windowScale uint8) api.RawPacket { + t.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 0x1234, + Flags: layers.IPv4DontFragment, + SrcIP: net.ParseIP(srcIP).To4(), + DstIP: net.ParseIP(dstIP).To4(), + Protocol: layers.IPProtocolTCP, + } + + tcp := &layers.TCP{ + SrcPort: layers.TCPPort(srcPort), + DstPort: layers.TCPPort(dstPort), + Seq: 1000, + SYN: true, + Window: 65535, + Options: []layers.TCPOption{ + { + OptionType: layers.TCPOptionKindMSS, + OptionLength: 4, + OptionData: []byte{byte(mss >> 8), byte(mss)}, + }, + { + OptionType: layers.TCPOptionKindWindowScale, + OptionLength: 3, + OptionData: []byte{windowScale}, + }, + { + OptionType: layers.TCPOptionKindSACKPermitted, + OptionLength: 2, + }, + }, + } + if err := tcp.SetNetworkLayerForChecksum(ip); err != nil { + t.Fatalf("SetNetworkLayerForChecksum() error = %v", err) + } + + eth := &layers.Ethernet{ + SrcMAC: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + DstMAC: net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}, + EthernetType: layers.EthernetTypeIPv4, + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ + FixLengths: true, + ComputeChecksums: true, + } + + if err := gopacket.SerializeLayers(buf, opts, eth, ip, tcp); err != nil { + t.Fatalf("SerializeLayers() error = %v", err) + } + + return api.RawPacket{ + Data: buf.Bytes(), + Timestamp: time.Now().UnixNano(), + LinkType: 1, + } +} + +func TestProcess_SYNCreatesFlowWithTCPMeta(t *testing.T) { + parser := NewParser() + defer parser.Close() + + srcIP := "192.168.1.50" + dstIP := "10.0.0.1" + srcPort := uint16(44444) + dstPort := uint16(443) + expectedMSS := uint16(1460) + expectedWS := uint8(7) + + // Step 1: Send SYN packet (should create flow, return nil) + synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, expectedMSS, expectedWS) + ch, err := parser.Process(synPkt) + if err != nil { + t.Fatalf("Process(SYN) error = %v", err) + } + if ch != nil { + t.Fatal("Process(SYN) should return nil (no ClientHello yet)") + } + + // Verify flow was created with correct metadata + key := flowKey(srcIP, srcPort, dstIP, dstPort) + parser.mu.RLock() + flow, exists := parser.flows[key] + parser.mu.RUnlock() + + if !exists { + t.Fatal("SYN should create a flow") + } + + flow.mu.Lock() + if flow.State != NEW { + t.Errorf("flow state = %v, want NEW", flow.State) + } + if flow.TCPMeta.MSS != expectedMSS { + t.Errorf("flow TCPMeta.MSS = %d, want %d", flow.TCPMeta.MSS, expectedMSS) + } + if flow.TCPMeta.WindowScale != expectedWS { + t.Errorf("flow TCPMeta.WindowScale = %d, want %d", flow.TCPMeta.WindowScale, expectedWS) + } + if flow.TCPMeta.WindowSize != 65535 { + t.Errorf("flow TCPMeta.WindowSize = %d, want 65535", flow.TCPMeta.WindowSize) + } + // Check SACK is in options + hasSACK := false + for _, opt := range flow.TCPMeta.Options { + if opt == "SACK" { + hasSACK = true + } + } + if !hasSACK { + t.Errorf("flow TCPMeta.Options = %v, want SACK", flow.TCPMeta.Options) + } + if flow.IPMeta.TTL != 64 { + t.Errorf("flow IPMeta.TTL = %d, want 64", flow.IPMeta.TTL) + } + if !flow.IPMeta.DF { + t.Error("flow IPMeta.DF should be true") + } + flow.mu.Unlock() + + // Step 2: Send ClientHello data packet (SYN had Seq=1000, so data starts at 1001) + clientHello := createTLSClientHello(0x0303) + dataPkt := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, clientHello, 1001) + + result, err := parser.Process(dataPkt) + if err != nil { + t.Fatalf("Process(ClientHello) error = %v", err) + } + if result == nil { + t.Fatal("Process(ClientHello) should return TLSClientHello") + } + + // Verify result uses TCP metadata from SYN, not from data packet + if result.TCPMeta.MSS != expectedMSS { + t.Errorf("result TCPMeta.MSS = %d, want %d (from SYN)", result.TCPMeta.MSS, expectedMSS) + } + if result.TCPMeta.WindowScale != expectedWS { + t.Errorf("result TCPMeta.WindowScale = %d, want %d (from SYN)", result.TCPMeta.WindowScale, expectedWS) + } + if result.IPMeta.TTL != 64 { + t.Errorf("result IPMeta.TTL = %d, want 64 (from SYN)", result.IPMeta.TTL) + } + if !result.IPMeta.DF { + t.Error("result IPMeta.DF should be true (from SYN)") + } +} + +func TestProcess_SynToCHMs_Timing(t *testing.T) { + parser := NewParser() + defer parser.Close() + + srcIP := "192.168.1.60" + dstIP := "10.0.0.1" + srcPort := uint16(55555) + dstPort := uint16(443) + + // Step 1: Send SYN + synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, 1460, 7) + _, err := parser.Process(synPkt) + if err != nil { + t.Fatalf("Process(SYN) error = %v", err) + } + + // Wait a measurable amount of time + time.Sleep(50 * time.Millisecond) + + // Step 2: Send ClientHello (SYN had Seq=1000, data at 1001) + clientHello := createTLSClientHello(0x0303) + dataPkt := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, clientHello, 1001) + + result, err := parser.Process(dataPkt) + if err != nil { + t.Fatalf("Process(ClientHello) error = %v", err) + } + if result == nil { + t.Fatal("Process(ClientHello) should return TLSClientHello") + } + + if result.SynToCHMs == nil { + t.Fatal("SynToCHMs should not be nil") + } + + // SynToCHMs should be at least 50ms (we slept 50ms) + if *result.SynToCHMs < 40 { + t.Errorf("SynToCHMs = %d ms, want >= 40ms (slept 50ms)", *result.SynToCHMs) + } +} + +func TestProcess_NoSYN_StillWorks(t *testing.T) { + // Ensure backward compatibility: if no SYN is seen (e.g. capture started + // mid-connection), a ClientHello data packet still creates a flow and works. + parser := NewParser() + defer parser.Close() + + srcIP := "192.168.1.70" + dstIP := "10.0.0.1" + srcPort := uint16(55666) + dstPort := uint16(443) + + clientHello := createTLSClientHello(0x0303) + dataPkt := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHello) + + result, err := parser.Process(dataPkt) + if err != nil { + t.Fatalf("Process() error = %v", err) + } + if result == nil { + t.Fatal("Process() should return TLSClientHello even without SYN") + } + if result.SrcIP != srcIP { + t.Errorf("SrcIP = %v, want %v", result.SrcIP, srcIP) + } +} + +func TestProcess_FragmentedClientHello_UsesFlowMeta(t *testing.T) { + parser := NewParser() + defer parser.Close() + + srcIP := "192.168.1.80" + dstIP := "10.0.0.1" + srcPort := uint16(33333) + dstPort := uint16(443) + expectedMSS := uint16(1460) + expectedWS := uint8(7) + + // Step 1: Send SYN with TCP options + synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, expectedMSS, expectedWS) + _, err := parser.Process(synPkt) + if err != nil { + t.Fatalf("Process(SYN) error = %v", err) + } + + // Step 2: Send incomplete TLS record (fragment 1, Seq=1001) + clientHello := createTLSClientHello(0x0303) + half := len(clientHello) / 2 + fragment1 := clientHello[:half] + + pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001) + ch, err := parser.Process(pkt1) + if err != nil { + t.Fatalf("Process(fragment1) error = %v", err) + } + if ch != nil { + t.Fatal("Process(fragment1) should return nil (incomplete)") + } + + // Step 3: Send rest (fragment 2, Seq=1001+len(fragment1)) + fragment2 := clientHello[half:] + pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment2, 1001+uint32(half)) + result, err := parser.Process(pkt2) + if err != nil { + t.Fatalf("Process(fragment2) error = %v", err) + } + if result == nil { + t.Fatal("Process(fragment2) should return complete TLSClientHello") + } + + // Verify metadata comes from the SYN (flow), not from the last data fragment + if result.TCPMeta.MSS != expectedMSS { + t.Errorf("result TCPMeta.MSS = %d, want %d (from SYN)", result.TCPMeta.MSS, expectedMSS) + } + if result.TCPMeta.WindowScale != expectedWS { + t.Errorf("result TCPMeta.WindowScale = %d, want %d (from SYN)", result.TCPMeta.WindowScale, expectedWS) + } + if result.IPMeta.TTL != 64 { + t.Errorf("result IPMeta.TTL = %d, want 64 (from SYN)", result.IPMeta.TTL) + } +} + +func TestProcess_TCPRetransmission_Ignored(t *testing.T) { + parser := NewParser() + defer parser.Close() + + srcIP := "192.168.1.90" + dstIP := "10.0.0.1" + srcPort := uint16(44321) + dstPort := uint16(443) + + // Step 1: Send SYN (Seq=1000) + synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, 1460, 7) + _, _ = parser.Process(synPkt) + + // Step 2: Send first fragment (Seq=1001) + clientHello := createTLSClientHello(0x0303) + half := len(clientHello) / 2 + fragment1 := clientHello[:half] + pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001) + _, _ = parser.Process(pkt1) + + // Step 3: Retransmit fragment 1 (same Seq=1001) — should be ignored + pkt1dup := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001) + ch, err := parser.Process(pkt1dup) + if err != nil { + t.Fatalf("Process(retransmit) error = %v", err) + } + if ch != nil { + t.Fatal("Process(retransmit) should return nil") + } + + // Step 4: Send second fragment (correct Seq) + fragment2 := clientHello[half:] + pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment2, 1001+uint32(half)) + result, err := parser.Process(pkt2) + if err != nil { + t.Fatalf("Process(fragment2) error = %v", err) + } + if result == nil { + t.Fatal("Process(fragment2) should return complete TLSClientHello after retransmission") + } +} + +func TestProcess_TCPGap_DropsFlow(t *testing.T) { + parser := NewParser() + defer parser.Close() + + srcIP := "192.168.1.91" + dstIP := "10.0.0.1" + srcPort := uint16(44322) + dstPort := uint16(443) + + // Step 1: Send SYN (Seq=1000) + synPkt := buildSYNPacket(t, srcIP, dstIP, srcPort, dstPort, 1460, 7) + _, _ = parser.Process(synPkt) + + // Step 2: Send first fragment (Seq=1001) + clientHello := createTLSClientHello(0x0303) + half := len(clientHello) / 2 + fragment1 := clientHello[:half] + pkt1 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment1, 1001) + _, _ = parser.Process(pkt1) + + // Step 3: Send fragment with gap (Seq far ahead) — should drop flow + fragment2 := clientHello[half:] + gapSeq := uint32(1001 + half + 100) // 100 bytes gap + pkt2 := buildRawPacketWithSeq(t, srcIP, dstIP, srcPort, dstPort, fragment2, gapSeq) + ch, err := parser.Process(pkt2) + if err != nil { + t.Fatalf("Process(gap) error = %v", err) + } + if ch != nil { + t.Fatal("Process(gap) should return nil") + } + + // Verify flow was removed + key := flowKey(srcIP, srcPort, dstIP, dstPort) + parser.mu.RLock() + _, exists := parser.flows[key] + parser.mu.RUnlock() + if exists { + t.Fatal("flow should be removed after sequence gap") + } +} + +// createTLS13ClientHelloWithSNI creates a TLS 1.3 ClientHello (record version 0x0303, +// supported_versions extension includes 0x0304) +func createTLS13ClientHelloWithSNI(sni string) []byte { + // Build SNI extension + sniExt := buildSNIExtension(sni) + + // Build ALPN extension + alpnExt := buildALPNExtension([]string{"h2", "http/1.1"}) + + // Build supported_versions extension with TLS 1.3 (0x0304) and TLS 1.2 (0x0303) + // Extension type: 43 (0x002b), data: list_len(1) + 2 versions (4 bytes) + supportedVersionsExt := []byte{ + 0x00, 0x2b, // Extension type: supported_versions (43) + 0x00, 0x05, // Extension data length: 5 + 0x04, // Supported versions list length: 4 bytes (2 versions) + 0x03, 0x04, // TLS 1.3 + 0x03, 0x03, // TLS 1.2 + } + + // Combine extensions + extensions := append(sniExt, alpnExt...) + extensions = append(extensions, supportedVersionsExt...) + extLen := len(extensions) + + // Cipher suites (TLS 1.3 suites) + cipherSuites := []byte{0x00, 0x04, 0x13, 0x01, 0x13, 0x02, 0xc0, 0x2f} + + // Compression methods (null only) + compressionMethods := []byte{0x01, 0x00} + + // Build ClientHello handshake body + handshakeBody := []byte{ + 0x03, 0x03, // Version: TLS 1.2 (mandatory for TLS 1.3 ClientHello) + } + // Random (32 bytes) + for i := 0; i < 32; i++ { + handshakeBody = append(handshakeBody, 0x01) + } + handshakeBody = append(handshakeBody, 0x00) // Session ID length: 0 + + // Add cipher suites + cipherSuiteLen := len(cipherSuites) + handshakeBody = append(handshakeBody, byte(cipherSuiteLen>>8), byte(cipherSuiteLen)) + handshakeBody = append(handshakeBody, cipherSuites...) + + // Add compression methods + handshakeBody = append(handshakeBody, compressionMethods...) + + // Add extensions + handshakeBody = append(handshakeBody, byte(extLen>>8), byte(extLen)) + handshakeBody = append(handshakeBody, extensions...) + + // Build handshake with type and length + handshakeLen := len(handshakeBody) + handshake := append([]byte{ + 0x01, // Handshake type: ClientHello + byte(handshakeLen >> 16), byte(handshakeLen >> 8), byte(handshakeLen), + }, handshakeBody...) + + // Build TLS record (version always 0x0303 for TLS 1.3) + recordLen := len(handshake) + record := make([]byte, 5+recordLen) + record[0] = 0x16 // Handshake + record[1] = 0x03 // TLS 1.2 in record layer (per TLS 1.3 spec) + record[2] = 0x03 + record[3] = byte(recordLen >> 8) + record[4] = byte(recordLen) + copy(record[5:], handshake) + + return record +} + +func TestExtractTLSExtensions_TLS13(t *testing.T) { + payload := createTLS13ClientHelloWithSNI("example.com") + + info, err := extractTLSExtensions(payload) + if err != nil { + t.Fatalf("extractTLSExtensions() error = %v", err) + } + if info == nil { + t.Fatal("extractTLSExtensions() returned nil") + } + + // TLS 1.3 should be detected via supported_versions extension + if info.TLSVersion != "1.3" { + t.Errorf("TLSVersion = %q, want \"1.3\"", info.TLSVersion) + } + if info.SNI != "example.com" { + t.Errorf("SNI = %q, want \"example.com\"", info.SNI) + } +} + +func TestProcess_TLS13ClientHello_CorrectVersion(t *testing.T) { + parser := NewParser() + defer parser.Close() + + srcIP := "192.168.1.200" + dstIP := "10.0.0.1" + srcPort := uint16(44555) + dstPort := uint16(443) + + clientHello := createTLS13ClientHelloWithSNI("tls13.example.com") + pkt := buildRawPacket(t, srcIP, dstIP, srcPort, dstPort, clientHello) + + result, err := parser.Process(pkt) + if err != nil { + t.Fatalf("Process() error = %v", err) + } + if result == nil { + t.Fatal("Process() should return TLSClientHello") + } + + if result.TLSVersion != "1.3" { + t.Errorf("TLSVersion = %q, want \"1.3\"", result.TLSVersion) + } + if result.SNI != "tls13.example.com" { + t.Errorf("SNI = %q, want \"tls13.example.com\"", result.SNI) + } +} diff --git a/packaging/rpm/ja4sentinel.spec b/packaging/rpm/ja4sentinel.spec index 3e39512..3a8fc3a 100644 --- a/packaging/rpm/ja4sentinel.spec +++ b/packaging/rpm/ja4sentinel.spec @@ -3,7 +3,7 @@ %if %{defined build_version} %define spec_version %{build_version} %else -%define spec_version 1.1.12 +%define spec_version 1.1.15 %endif Name: ja4sentinel @@ -123,6 +123,26 @@ fi %changelog +* Thu Mar 05 2026 Jacquin Antoine - 1.1.15-1 +- FIX: ALPN not appearing in logs for packets with truncated/malformed TLS extensions +- Add sanitization fallback in extractTLSExtensions (same as fingerprint engine) +- ALPN (tls_alpn) now correctly extracted even when ParseClientHello fails on raw payload + +* Thu Mar 05 2026 Jacquin Antoine - 1.1.14-1 +- FIX: Handle ClientHellos with truncated extension data (extension data truncated) +- Sanitize malformed extensions by trimming to last complete extension before retry +- Fingerprints (JA4/JA3) now generated even for slightly malformed ClientHellos +- Added unit tests for extension sanitization and truncated extension handling + +* Thu Mar 05 2026 Jacquin Antoine - 1.1.13-1 +- FIX: BPF filter uses 'tcp dst port' instead of 'tcp port' to capture client-to-server traffic only +- FIX: SYN packet handling — detect SYN before payload-length check, create flow with IP/TCP metadata +- FIX: SynToCHMs timing now uses SYN timestamp instead of first data packet timestamp +- FIX: Fragmented ClientHello uses flow metadata from SYN instead of last fragment's packet metadata +- FIX: TCP reassembly sequence tracking — detect retransmissions (skip) and gaps (drop flow) +- Added TLS 1.3 supported_versions test coverage (verified library already handles it correctly) +- 9 new unit tests for SYN handling, TCP reassembly, TLS 1.3, and fragmentation metadata + * Thu Mar 05 2026 Jacquin Antoine - 1.1.12-1 - FIX: Remove JA4SENTINEL_LOG_LEVEL env override (architecture violation, log_level YAML-only) - FIX: Add yaml struct tags to Config/AppConfig/OutputConfig (yaml.v3 does not fall back to json tags)