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

@ -50,19 +50,26 @@ type RawPacket struct {
// TLSClientHello represents a client-side TLS ClientHello with IP/TCP metadata
type TLSClientHello struct {
SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"`
Payload []byte `json:"-"` // Not serialized
IPMeta IPMeta `json:"ip_meta"`
TCPMeta TCPMeta `json:"tcp_meta"`
SrcIP string `json:"src_ip"`
SrcPort uint16 `json:"src_port"`
DstIP string `json:"dst_ip"`
DstPort uint16 `json:"dst_port"`
Payload []byte `json:"-"` // Not serialized
IPMeta IPMeta `json:"ip_meta"`
TCPMeta TCPMeta `json:"tcp_meta"`
ConnID string `json:"conn_id,omitempty"` // Unique flow identifier
SNI string `json:"tls_sni,omitempty"` // Server Name Indication
ALPN string `json:"tls_alpn,omitempty"` // Application-Layer Protocol Negotiation
TLSVersion string `json:"tls_version,omitempty"` // Max TLS version supported
SynToCHMs *uint32 `json:"syn_to_clienthello_ms,omitempty"` // Time from SYN to ClientHello (ms)
}
// Fingerprints contains TLS fingerprints for a client flow
// Note: JA4Hash is kept for internal use but not serialized to LogRecord
// as the JA4 format already includes its own hash portions
type Fingerprints struct {
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA4Hash string `json:"ja4_hash,omitempty"` // Internal use, not serialized to LogRecord
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
}
@ -81,14 +88,26 @@ type LogRecord struct {
IPDF bool `json:"ip_meta_df"`
// Flattened TCPMeta fields
TCPWindow uint16 `json:"tcp_meta_window_size"`
TCPWindow uint16 `json:"tcp_meta_window_size"`
TCPMSS *uint16 `json:"tcp_meta_mss,omitempty"`
TCPWScale *uint8 `json:"tcp_meta_window_scale,omitempty"`
TCPOptions string `json:"tcp_meta_options"` // comma-separated list
TCPOptions string `json:"tcp_meta_options"` // comma-separated list
// Correlation & Triage
ConnID string `json:"conn_id,omitempty"` // Unique flow identifier
SensorID string `json:"sensor_id,omitempty"` // Sensor/captor identifier
// TLS elements (ClientHello)
TLSVersion string `json:"tls_version,omitempty"` // Max TLS version announced by client
SNI string `json:"tls_sni,omitempty"` // Server Name Indication
ALPN string `json:"tls_alpn,omitempty"` // Application-Layer Protocol Negotiation
// Behavioral detection (Timing)
SynToCHMs *uint32 `json:"syn_to_clienthello_ms,omitempty"` // Time from SYN to ClientHello (ms)
// Fingerprints
// Note: ja4_hash is NOT included - the JA4 format already includes its own hash portions
JA4 string `json:"ja4"`
JA4Hash string `json:"ja4_hash"`
JA3 string `json:"ja3,omitempty"`
JA3Hash string `json:"ja3_hash,omitempty"`
@ -98,9 +117,10 @@ type LogRecord struct {
// OutputConfig defines configuration for a single log output
type OutputConfig struct {
Type string `json:"type"` // unix_socket, stdout, file, etc.
Enabled bool `json:"enabled"` // whether this output is active
Params map[string]string `json:"params"` // specific parameters like socket_path, path, etc.
Type string `json:"type"` // unix_socket, stdout, file, etc.
Enabled bool `json:"enabled"` // whether this output is active
AsyncBuffer int `json:"async_buffer"` // queue size for async writes (e.g., 5000)
Params map[string]string `json:"params"` // specific parameters like socket_path, path, etc.
}
// AppConfig is the complete ja4sentinel configuration
@ -191,6 +211,8 @@ type Logger interface {
// Converts TCPMeta options to a comma-separated string and creates pointer values
// for optional fields (MSS, WindowScale) to support proper JSON omitempty behavior.
// If fingerprints is nil, the JA4/JA3 fields will be empty strings.
// Note: JA4Hash is intentionally NOT included in LogRecord as the JA4 format
// already includes its own hash portions (the full 38-character JA4 string is sufficient).
func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
opts := ""
if len(ch.TCPMeta.Options) > 0 {
@ -221,12 +243,16 @@ func NewLogRecord(ch TLSClientHello, fp *Fingerprints) LogRecord {
TCPMSS: mssPtr,
TCPWScale: wScalePtr,
TCPOptions: opts,
ConnID: ch.ConnID,
SNI: ch.SNI,
ALPN: ch.ALPN,
TLSVersion: ch.TLSVersion,
SynToCHMs: ch.SynToCHMs,
Timestamp: time.Now().UnixNano(),
}
if fp != nil {
rec.JA4 = fp.JA4
rec.JA4Hash = fp.JA4Hash
rec.JA3 = fp.JA3
rec.JA3Hash = fp.JA3Hash
}

View File

@ -5,6 +5,7 @@ import (
)
func TestNewLogRecord(t *testing.T) {
synToCHMs := uint32(150)
tests := []struct {
name string
clientHello TLSClientHello
@ -18,6 +19,11 @@ func TestNewLogRecord(t *testing.T) {
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
ConnID: "flow-abc123",
SNI: "example.com",
ALPN: "h2",
TLSVersion: "1.3",
SynToCHMs: &synToCHMs,
IPMeta: IPMeta{
TTL: 64,
TotalLength: 512,
@ -33,7 +39,7 @@ func TestNewLogRecord(t *testing.T) {
},
fingerprints: &Fingerprints{
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775", // Internal use only
JA3: "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
},
@ -46,6 +52,10 @@ func TestNewLogRecord(t *testing.T) {
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
ConnID: "flow-xyz789",
SNI: "test.example.com",
ALPN: "http/1.1",
TLSVersion: "1.2",
IPMeta: IPMeta{
TTL: 64,
TotalLength: 512,
@ -154,14 +164,34 @@ func TestNewLogRecord(t *testing.T) {
}
}
// Verify fingerprints
// Verify new TLS fields
if rec.ConnID != tt.clientHello.ConnID {
t.Errorf("ConnID = %v, want %v", rec.ConnID, tt.clientHello.ConnID)
}
if rec.SNI != tt.clientHello.SNI {
t.Errorf("SNI = %v, want %v", rec.SNI, tt.clientHello.SNI)
}
if rec.ALPN != tt.clientHello.ALPN {
t.Errorf("ALPN = %v, want %v", rec.ALPN, tt.clientHello.ALPN)
}
if rec.TLSVersion != tt.clientHello.TLSVersion {
t.Errorf("TLSVersion = %v, want %v", rec.TLSVersion, tt.clientHello.TLSVersion)
}
if tt.clientHello.SynToCHMs != nil {
if rec.SynToCHMs == nil {
t.Error("SynToCHMs should not be nil")
} else if *rec.SynToCHMs != *tt.clientHello.SynToCHMs {
t.Errorf("SynToCHMs = %v, want %v", *rec.SynToCHMs, *tt.clientHello.SynToCHMs)
}
}
// Verify fingerprints (note: JA4Hash is NOT in LogRecord per architecture)
if tt.fingerprints != nil {
if rec.JA4 != tt.fingerprints.JA4 {
t.Errorf("JA4 = %v, want %v", rec.JA4, tt.fingerprints.JA4)
}
if rec.JA4Hash != tt.fingerprints.JA4Hash {
t.Errorf("JA4Hash = %v, want %v", rec.JA4Hash, tt.fingerprints.JA4Hash)
}
// JA4Hash is intentionally NOT in LogRecord (architecture decision)
// JA3Hash is still present as it's the MD5 of JA3 (needed for exploitation)
if rec.JA3 != tt.fingerprints.JA3 {
t.Errorf("JA3 = %v, want %v", rec.JA3, tt.fingerprints.JA3)
}
@ -223,3 +253,88 @@ func TestLogRecordConversion(t *testing.T) {
t.Errorf("TCPOptions = %v, want %v", rec.TCPOptions, expectedOpts)
}
}
func TestLogRecordNoJA4Hash(t *testing.T) {
// Verify that JA4Hash is NOT included in LogRecord per architecture decision
clientHello := TLSClientHello{
SrcIP: "192.168.1.100",
SrcPort: 54321,
DstIP: "10.0.0.1",
DstPort: 443,
}
fingerprints := &Fingerprints{
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
JA4Hash: "8daaf6152771_02cb136f2775", // Should NOT appear in LogRecord
JA3: "771,4865-4866-4867,0-23-65281,29-23-24,0",
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
}
rec := NewLogRecord(clientHello, fingerprints)
// JA4Hash is NOT in LogRecord (architecture decision)
// The JA4 format already includes its own hash portions
// But JA4 should be present
if rec.JA4 != fingerprints.JA4 {
t.Errorf("JA4 = %v, want %v", rec.JA4, fingerprints.JA4)
}
// JA3Hash should still be present (it's the MD5 of JA3, which is needed)
if rec.JA3Hash != fingerprints.JA3Hash {
t.Errorf("JA3Hash = %v, want %v", rec.JA3Hash, fingerprints.JA3Hash)
}
}
func TestOutputConfig(t *testing.T) {
tests := []struct {
name string
config OutputConfig
wantEnabled bool
wantAsyncBuf int
}{
{
name: "stdout output with async buffer",
config: OutputConfig{
Type: "stdout",
Enabled: true,
AsyncBuffer: 5000,
Params: map[string]string{},
},
wantEnabled: true,
wantAsyncBuf: 5000,
},
{
name: "unix_socket output with default async buffer",
config: OutputConfig{
Type: "unix_socket",
Enabled: true,
AsyncBuffer: 0, // Default
Params: map[string]string{"socket_path": "/var/run/test.sock"},
},
wantEnabled: true,
wantAsyncBuf: 0,
},
{
name: "disabled output",
config: OutputConfig{
Type: "file",
Enabled: false,
AsyncBuffer: 1000,
Params: map[string]string{"path": "/var/log/test.log"},
},
wantEnabled: false,
wantAsyncBuf: 1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.config.Enabled != tt.wantEnabled {
t.Errorf("Enabled = %v, want %v", tt.config.Enabled, tt.wantEnabled)
}
if tt.config.AsyncBuffer != tt.wantAsyncBuf {
t.Errorf("AsyncBuffer = %v, want %v", tt.config.AsyncBuffer, tt.wantAsyncBuf)
}
})
}
}