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
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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user