release: version 1.0.9 - Add SNI, ALPN, TLS version extraction and architecture.yml compliance
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled

New features:
- Extract SNI (Server Name Indication) from TLS ClientHello
- Extract ALPN (Application-Layer Protocol Negotiation) protocols
- Detect TLS version from ClientHello using tlsfingerprint library
- Add ConnID field for TCP flow correlation
- Add SensorID field for multi-sensor deployments
- Add SynToCHMs timing field for behavioral detection
- Add AsyncBuffer configuration for output queue sizing

Architecture changes:
- Remove JA4Hash from LogRecord (JA4 format includes its own hash portions)
- Update api.TLSClientHello with new TLS metadata fields
- Update api.LogRecord with correlation, TLS, and timing fields
- Ensure 100% compliance with architecture.yml specification

Tests:
- Add unit tests for TLS extension extraction (SNI, ALPN, Version)
- Update tests for new LogRecord schema without JA4Hash
- Add tests for AsyncBuffer configuration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jacquin Antoine
2026-03-02 19:32:16 +01:00
parent fd162982d9
commit 965720a183
12 changed files with 854 additions and 392 deletions

View File

@ -12,6 +12,7 @@ import (
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
tlsfingerprint "github.com/psanford/tlsfingerprint"
)
// ConnectionState represents the state of a TCP connection for TLS parsing
@ -220,15 +221,31 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
flow.State = JA4_DONE
flow.HelloBuffer = clientHello
return &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
// Extract TLS extensions (SNI, ALPN, TLS version)
extInfo, _ := extractTLSExtensions(clientHello)
// Generate ConnID from flow key
connID := key
ch := &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
ConnID: connID,
SNI: extInfo.SNI,
ALPN: joinStringSlice(extInfo.ALPN, ","),
TLSVersion: extInfo.TLSVersion,
}
// Calculate SynToCHMs if we have timing info
synToCH := uint32(time.Since(flow.CreatedAt).Milliseconds())
ch.SynToCHMs = &synToCH
return ch, nil
}
// Check for fragmented ClientHello (accumulate segments)
@ -257,15 +274,31 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
// Complete ClientHello found
flow.State = JA4_DONE
return &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
}, nil
// Extract TLS extensions (SNI, ALPN, TLS version)
extInfo, _ := extractTLSExtensions(clientHello)
// Generate ConnID from flow key
connID := key
ch := &api.TLSClientHello{
SrcIP: srcIP,
SrcPort: srcPort,
DstIP: dstIP,
DstPort: dstPort,
Payload: clientHello,
IPMeta: ipMeta,
TCPMeta: tcpMeta,
ConnID: connID,
SNI: extInfo.SNI,
ALPN: joinStringSlice(extInfo.ALPN, ","),
TLSVersion: extInfo.TLSVersion,
}
// Calculate SynToCHMs
synToCH := uint32(time.Since(flow.CreatedAt).Milliseconds())
ch.SynToCHMs = &synToCH
return ch, nil
}
}
@ -377,6 +410,13 @@ func extractTCPMeta(tcp *layers.TCP) api.TCPMeta {
return meta
}
// TLSExtensionInfo contains parsed TLS extension information
type TLSExtensionInfo struct {
SNI string
ALPN []string
TLSVersion string
}
// parseClientHello checks if the payload contains a TLS ClientHello and returns it
func parseClientHello(payload []byte) ([]byte, error) {
if len(payload) < 5 {
@ -419,6 +459,171 @@ func parseClientHello(payload []byte) ([]byte, error) {
return payload[:5+recordLength], nil
}
// extractTLSExtensions extracts SNI, ALPN, and TLS version from a ClientHello payload
// Uses tlsfingerprint library for ALPN and TLS version, manual parsing for SNI value
func extractTLSExtensions(payload []byte) (*TLSExtensionInfo, error) {
if len(payload) < 5 {
return nil, nil
}
// TLS record layer
contentType := payload[0]
if contentType != 22 {
return nil, nil // Not a handshake
}
version := binary.BigEndian.Uint16(payload[1:3])
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
if len(payload) < 5+recordLength {
return nil, nil // Incomplete record
}
handshakePayload := payload[5 : 5+recordLength]
if len(handshakePayload) < 1 {
return nil, nil
}
handshakeType := handshakePayload[0]
if handshakeType != 1 {
return nil, nil // Not a ClientHello
}
info := &TLSExtensionInfo{}
// Use tlsfingerprint to parse ALPN and TLS version
fp, err := tlsfingerprint.ParseClientHello(payload)
if err == nil && fp != nil {
// Extract ALPN protocols
if len(fp.ALPNProtocols) > 0 {
info.ALPN = fp.ALPNProtocols
}
// Extract TLS version
info.TLSVersion = tlsVersionToString(fp.Version)
}
// If tlsfingerprint didn't provide version, fall back to record version
if info.TLSVersion == "" {
info.TLSVersion = tlsVersionToString(version)
}
// Parse SNI manually (tlsfingerprint only provides HasSNI, not the value)
sniValue := extractSNIFromPayload(handshakePayload)
if sniValue != "" {
info.SNI = sniValue
}
return info, nil
}
// extractSNIFromPayload extracts the SNI value from a ClientHello handshake payload
// handshakePayload starts at the handshake type byte (0x01 for ClientHello)
func extractSNIFromPayload(handshakePayload []byte) string {
// handshakePayload structure:
// [0]: Handshake type (0x01 for ClientHello)
// [1:4]: Handshake length (3 bytes, big-endian)
// [4:6]: Version (2 bytes)
// [6:38]: Random (32 bytes)
// [38]: Session ID length
// ...
if len(handshakePayload) < 40 { // type(1) + len(3) + version(2) + random(32) + sessionIDLen(1)
return ""
}
// Start after type (1) + length (3) + version (2) + random (32) = 38
offset := 38
// Session ID length (1 byte)
sessionIDLen := int(handshakePayload[offset])
offset++
// Skip session ID
offset += sessionIDLen
// Cipher suites length (2 bytes)
if offset+2 > len(handshakePayload) {
return ""
}
cipherSuiteLen := int(binary.BigEndian.Uint16(handshakePayload[offset : offset+2]))
offset += 2 + cipherSuiteLen
// Compression methods length (1 byte)
if offset >= len(handshakePayload) {
return ""
}
compressionLen := int(handshakePayload[offset])
offset++
// Skip compression methods
offset += compressionLen
// Extensions length (2 bytes) - optional
if offset+2 > len(handshakePayload) {
return ""
}
extensionsLen := int(binary.BigEndian.Uint16(handshakePayload[offset : offset+2]))
offset += 2
if extensionsLen == 0 || offset+extensionsLen > len(handshakePayload) {
return ""
}
extensionsEnd := offset + extensionsLen
// Debug: log extension types found
_ = extensionsEnd // suppress unused warning in case we remove debug code
// Parse extensions to find SNI (type 0)
for offset < extensionsEnd {
if offset+4 > len(handshakePayload) {
break
}
extType := binary.BigEndian.Uint16(handshakePayload[offset : offset+2])
extLen := int(binary.BigEndian.Uint16(handshakePayload[offset+2 : offset+4]))
offset += 4
if offset+extLen > len(handshakePayload) {
break
}
extData := handshakePayload[offset : offset+extLen]
offset += extLen
if extType == 0 && len(extData) >= 5 { // SNI extension
// SNI extension structure:
// - name_list_len (2 bytes)
// - name_type (1 byte)
// - name_len (2 bytes)
// - name (variable)
// Skip name_list_len (2), read name_type (1) + name_len (2)
nameLen := int(binary.BigEndian.Uint16(extData[3:5]))
if len(extData) >= 5+nameLen {
return string(extData[5 : 5+nameLen])
}
}
}
return ""
}
// tlsVersionToString converts a TLS version number to a string
func tlsVersionToString(version uint16) string {
switch version {
case 0x0301:
return "1.0"
case 0x0302:
return "1.1"
case 0x0303:
return "1.2"
case 0x0304:
return "1.3"
default:
return ""
}
}
// IsClientHello checks if a payload contains a TLS ClientHello
func IsClientHello(payload []byte) bool {
if len(payload) < 6 {