feat: ja4-platform monorepo — 5 services unified, tests & RPM builds standardized
Services: - ja4sentinel: TLS/JA4 fingerprint capture daemon (Go, libpcap) - logcorrelator: JA4 log correlation engine (Go, ClickHouse) - mod_reqin_log: Apache module (C, JSON request logging) - bot_detector: ML bot detection pipeline (Python) - dashboard: FastAPI/Streamlit analytics UI (Python) Shared libraries: - shared/go/ja4common: logger, config, shutdown, ipfilter (Go module) - shared/python/ja4_common: ClickHouseClient, ClickHouseSettings (Python package) - shared/clickhouse/: canonical SQL migrations (10 files) Build & packaging: - Unified 3-stage Dockerfile.package for Go RPMs (el8/el9/el10) - go.work workspace linking sentinel, correlator, ja4common - Makefile with test-all, build-all, rpm-* targets Fixes applied: - go.work: 1.21 → 1.24.6 (required by sentinel) - correlator Dockerfiles: golang:1.21 → golang:1.24 - replace directives in go.mod for ja4common local path - pyproject.toml: setuptools.backends → setuptools.build_meta - Removed static libpcap linking (unavailable on Rocky 9) - Fixed data races in output/writers_test.go (sync.Mutex + atomic.Int32) - Rewrote corrupted test files (logger_test.go × 2) Test coverage: - correlator: 67.1% total (unixsocket 80.5%, config 91.7%, app 83.3%, multi 87.7%, stdout 100%) - sentinel: all 10 packages pass (api, capture, config, fingerprint, ipfilter, logging, output, tlsparse) Documentation: - README.md + docs/ (architecture, development, 5 services, shared libs, DB schema & migrations) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
303
services/sentinel/api/types.go
Normal file
303
services/sentinel/api/types.go
Normal file
@ -0,0 +1,303 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServiceLog represents internal service logging for diagnostics
|
||||
type ServiceLog struct {
|
||||
Level string `json:"level"`
|
||||
Component string `json:"component"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"` // Unix nanoseconds (auto-set by logger)
|
||||
TraceID string `json:"trace_id,omitempty"` // Optional distributed tracing ID
|
||||
ConnID string `json:"conn_id,omitempty"` // Optional TCP flow identifier
|
||||
}
|
||||
|
||||
// Config holds basic network and TLS configuration
|
||||
type Config struct {
|
||||
Interface string `yaml:"interface" json:"interface"`
|
||||
ListenPorts []uint16 `yaml:"listen_ports" json:"listen_ports"`
|
||||
BPFFilter string `yaml:"bpf_filter" json:"bpf_filter,omitempty"`
|
||||
LocalIPs []string `yaml:"local_ips" json:"local_ips,omitempty"` // Local IPs to monitor (empty = auto-detect, excludes loopback)
|
||||
ExcludeSourceIPs []string `yaml:"exclude_source_ips" json:"exclude_source_ips,omitempty"` // Source IPs or CIDR ranges to exclude (e.g., ["10.0.0.0/8", "192.168.1.1"])
|
||||
FlowTimeoutSec int `yaml:"flow_timeout_sec" json:"flow_timeout_sec,omitempty"` // Timeout for TLS handshake extraction (default: 30)
|
||||
PacketBufferSize int `yaml:"packet_buffer_size" json:"packet_buffer_size,omitempty"` // Buffer size for packet channel (default: 1000)
|
||||
LogLevel string `yaml:"log_level" json:"log_level,omitempty"` // Log level: debug, info, warn, error (default: info)
|
||||
}
|
||||
|
||||
// IPMeta contains IP metadata for stack fingerprinting
|
||||
type IPMeta struct {
|
||||
TTL uint8 `json:"ttl"`
|
||||
TotalLength uint16 `json:"total_length"`
|
||||
IPID uint16 `json:"id"`
|
||||
DF bool `json:"df"`
|
||||
}
|
||||
|
||||
// TCPMeta contains TCP metadata for stack fingerprinting
|
||||
type TCPMeta struct {
|
||||
WindowSize uint16 `json:"window_size"`
|
||||
MSS uint16 `json:"mss,omitempty"`
|
||||
WindowScale uint8 `json:"window_scale,omitempty"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
// RawPacket represents a raw packet captured from the network
|
||||
type RawPacket struct {
|
||||
Data []byte `json:"-"` // Raw packet data including link-layer header
|
||||
Timestamp int64 `json:"timestamp"` // nanoseconds since epoch
|
||||
LinkType int `json:"-"` // Link type (1=Ethernet, 101=Linux SLL, etc.)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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,omitempty"` // Internal use, not serialized to LogRecord
|
||||
JA3 string `json:"ja3,omitempty"`
|
||||
JA3Hash string `json:"ja3_hash,omitempty"`
|
||||
}
|
||||
|
||||
// LogRecord is the final log record, serialized as a flat JSON object
|
||||
type LogRecord struct {
|
||||
SrcIP string `json:"src_ip"`
|
||||
SrcPort uint16 `json:"src_port"`
|
||||
DstIP string `json:"dst_ip"`
|
||||
DstPort uint16 `json:"dst_port"`
|
||||
|
||||
// Flattened IPMeta fields
|
||||
IPTTL uint8 `json:"ip_meta_ttl"`
|
||||
IPTotalLen uint16 `json:"ip_meta_total_length"`
|
||||
IPID uint16 `json:"ip_meta_id"`
|
||||
IPDF bool `json:"ip_meta_df"`
|
||||
|
||||
// Flattened TCPMeta fields
|
||||
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
|
||||
|
||||
// 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"`
|
||||
JA3 string `json:"ja3,omitempty"`
|
||||
JA3Hash string `json:"ja3_hash,omitempty"`
|
||||
|
||||
// Timestamp in nanoseconds since Unix epoch
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// OutputConfig defines configuration for a single log output
|
||||
type OutputConfig struct {
|
||||
Type string `yaml:"type" json:"type"` // unix_socket, stdout, file, etc.
|
||||
Enabled bool `yaml:"enabled" json:"enabled"` // whether this output is active
|
||||
AsyncBuffer int `yaml:"async_buffer" json:"async_buffer"` // queue size for async writes (e.g., 5000)
|
||||
Params map[string]string `yaml:"params" json:"params"` // specific parameters like socket_path, path, etc.
|
||||
}
|
||||
|
||||
// AppConfig is the complete ja4sentinel configuration
|
||||
type AppConfig struct {
|
||||
Core Config `yaml:"core" json:"core"`
|
||||
Outputs []OutputConfig `yaml:"outputs" json:"outputs"`
|
||||
}
|
||||
|
||||
// Loader defines the interface for loading application configuration.
|
||||
// Implementations must read configuration from a YAML file, merge with
|
||||
// environment variables (JA4SENTINEL_*), and validate the final result.
|
||||
type Loader interface {
|
||||
Load() (AppConfig, error)
|
||||
}
|
||||
|
||||
// Capture defines the interface for capturing raw network packets.
|
||||
// Implementations must listen on a configured network interface, apply
|
||||
// BPF filters for specified ports, and emit RawPacket objects to a channel.
|
||||
// The Close method must be called to release resources (e.g., pcap handle).
|
||||
type Capture interface {
|
||||
Run(cfg Config, out chan<- RawPacket) error
|
||||
Close() error
|
||||
GetStats() (received, sent, dropped uint64)
|
||||
}
|
||||
|
||||
// Parser defines the interface for extracting TLS ClientHello messages
|
||||
// from raw network packets. Implementations must track TCP connection states,
|
||||
// reassemble fragmented handshakes, and return TLSClientHello objects with
|
||||
// IP/TCP metadata. Returns nil for non-TLS or non-ClientHello packets.
|
||||
type Parser interface {
|
||||
Process(pkt RawPacket) (*TLSClientHello, error)
|
||||
Close() error
|
||||
GetMetrics() (retransmit, gapDetected, bufferExceeded, segmentExceeded uint64)
|
||||
}
|
||||
|
||||
// Engine defines the interface for generating TLS fingerprints.
|
||||
// Implementations must analyze TLS ClientHello payloads and produce
|
||||
// JA4 (required) and optionally JA3 fingerprint strings.
|
||||
type Engine interface {
|
||||
FromClientHello(ch TLSClientHello) (*Fingerprints, error)
|
||||
}
|
||||
|
||||
// Writer defines the generic interface for writing log records.
|
||||
// Implementations must serialize LogRecord objects and send them to
|
||||
// a destination (stdout, file, UNIX socket, etc.).
|
||||
type Writer interface {
|
||||
Write(rec LogRecord) error
|
||||
}
|
||||
|
||||
// UnixSocketWriter extends Writer with a Close method for UNIX socket cleanup.
|
||||
// Implementations must connect to a UNIX socket at the specified path and
|
||||
// write JSON-encoded LogRecord objects. Reconnection logic should be
|
||||
// implemented for transient socket failures.
|
||||
type UnixSocketWriter interface {
|
||||
Writer
|
||||
Close() error
|
||||
}
|
||||
|
||||
// MultiWriter extends Writer to support multiple output destinations.
|
||||
// Implementations must write each LogRecord to all registered writers
|
||||
// and provide methods to add writers and close all connections.
|
||||
type MultiWriter interface {
|
||||
Writer
|
||||
Add(writer Writer)
|
||||
CloseAll() error
|
||||
}
|
||||
|
||||
// Builder defines the interface for constructing output writers from configuration.
|
||||
// Implementations must parse AppConfig.Outputs and create appropriate Writer
|
||||
// instances (StdoutWriter, FileWriter, UnixSocketWriter), combining them
|
||||
// into a MultiWriter if multiple outputs are configured.
|
||||
type Builder interface {
|
||||
NewFromConfig(cfg AppConfig) (Writer, error)
|
||||
}
|
||||
|
||||
// Logger defines the interface for structured service logging.
|
||||
// Implementations must emit JSON-formatted log entries to stdout/stderr
|
||||
// with support for multiple log levels (DEBUG, INFO, WARN, ERROR).
|
||||
// Each log entry includes timestamp, level, component, message, and optional details.
|
||||
type Logger interface {
|
||||
Debug(component, message string, details map[string]string)
|
||||
Info(component, message string, details map[string]string)
|
||||
Warn(component, message string, details map[string]string)
|
||||
Error(component, message string, details map[string]string)
|
||||
}
|
||||
|
||||
// Reopenable defines the interface for components that support log file rotation.
|
||||
// Implementations must reopen their output files when receiving a SIGHUP signal.
|
||||
// This is used by systemctl reload to switch to new log files after logrotate.
|
||||
type Reopenable interface {
|
||||
Reopen() error
|
||||
}
|
||||
|
||||
// Helper functions for creating and converting records
|
||||
|
||||
// NewLogRecord creates a flattened LogRecord from TLSClientHello and Fingerprints.
|
||||
// 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 {
|
||||
opts = strings.Join(ch.TCPMeta.Options, ",")
|
||||
}
|
||||
|
||||
// Helper to create pointer from value for optional fields
|
||||
var mssPtr *uint16
|
||||
if ch.TCPMeta.MSS != 0 {
|
||||
mssPtr = &ch.TCPMeta.MSS
|
||||
}
|
||||
|
||||
var wScalePtr *uint8
|
||||
if ch.TCPMeta.WindowScale != 0 {
|
||||
wScalePtr = &ch.TCPMeta.WindowScale
|
||||
}
|
||||
|
||||
rec := LogRecord{
|
||||
SrcIP: ch.SrcIP,
|
||||
SrcPort: ch.SrcPort,
|
||||
DstIP: ch.DstIP,
|
||||
DstPort: ch.DstPort,
|
||||
IPTTL: ch.IPMeta.TTL,
|
||||
IPTotalLen: ch.IPMeta.TotalLength,
|
||||
IPID: ch.IPMeta.IPID,
|
||||
IPDF: ch.IPMeta.DF,
|
||||
TCPWindow: ch.TCPMeta.WindowSize,
|
||||
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.JA3 = fp.JA3
|
||||
rec.JA3Hash = fp.JA3Hash
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// Default values and constants
|
||||
|
||||
const (
|
||||
DefaultInterface = "eth0"
|
||||
DefaultPort = 443
|
||||
DefaultBPFFilter = ""
|
||||
DefaultFlowTimeout = 30 // seconds
|
||||
DefaultPacketBuffer = 1000 // packet channel buffer size
|
||||
DefaultLogLevel = "info"
|
||||
)
|
||||
|
||||
// DefaultConfig returns an AppConfig with sensible default values.
|
||||
// Uses eth0 as the default interface, port 443 for monitoring,
|
||||
// no BPF filter, a 30-second flow timeout, and a 1000-packet
|
||||
// channel buffer. Returns an empty outputs slice (caller must
|
||||
// configure outputs explicitly).
|
||||
func DefaultConfig() AppConfig {
|
||||
return AppConfig{
|
||||
Core: Config{
|
||||
Interface: DefaultInterface,
|
||||
ListenPorts: []uint16{DefaultPort},
|
||||
BPFFilter: DefaultBPFFilter,
|
||||
FlowTimeoutSec: DefaultFlowTimeout,
|
||||
PacketBufferSize: DefaultPacketBuffer,
|
||||
LogLevel: DefaultLogLevel,
|
||||
},
|
||||
Outputs: []OutputConfig{},
|
||||
}
|
||||
}
|
||||
340
services/sentinel/api/types_test.go
Normal file
340
services/sentinel/api/types_test.go
Normal file
@ -0,0 +1,340 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewLogRecord(t *testing.T) {
|
||||
synToCHMs := uint32(150)
|
||||
tests := []struct {
|
||||
name string
|
||||
clientHello TLSClientHello
|
||||
fingerprints *Fingerprints
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "complete record with fingerprints",
|
||||
clientHello: TLSClientHello{
|
||||
SrcIP: "192.168.1.100",
|
||||
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,
|
||||
IPID: 12345,
|
||||
DF: true,
|
||||
},
|
||||
TCPMeta: TCPMeta{
|
||||
WindowSize: 65535,
|
||||
MSS: 1460,
|
||||
WindowScale: 7,
|
||||
Options: []string{"MSS", "WS", "SACK", "TS"},
|
||||
},
|
||||
},
|
||||
fingerprints: &Fingerprints{
|
||||
JA4: "t13d1516h2_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",
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "record without fingerprints",
|
||||
clientHello: TLSClientHello{
|
||||
SrcIP: "192.168.1.100",
|
||||
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,
|
||||
IPID: 12345,
|
||||
DF: true,
|
||||
},
|
||||
TCPMeta: TCPMeta{
|
||||
WindowSize: 65535,
|
||||
MSS: 1460,
|
||||
WindowScale: 7,
|
||||
Options: []string{"MSS", "WS"},
|
||||
},
|
||||
},
|
||||
fingerprints: nil,
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "record with zero values for optional fields",
|
||||
clientHello: TLSClientHello{
|
||||
SrcIP: "192.168.1.100",
|
||||
SrcPort: 54321,
|
||||
DstIP: "10.0.0.1",
|
||||
DstPort: 443,
|
||||
IPMeta: IPMeta{
|
||||
TTL: 0,
|
||||
TotalLength: 0,
|
||||
IPID: 0,
|
||||
DF: false,
|
||||
},
|
||||
TCPMeta: TCPMeta{
|
||||
WindowSize: 0,
|
||||
MSS: 0,
|
||||
WindowScale: 0,
|
||||
Options: []string{},
|
||||
},
|
||||
},
|
||||
fingerprints: nil,
|
||||
wantNil: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := NewLogRecord(tt.clientHello, tt.fingerprints)
|
||||
|
||||
// Verify timestamp is set
|
||||
if rec.Timestamp == 0 {
|
||||
t.Error("Timestamp should be set")
|
||||
}
|
||||
|
||||
// Verify basic fields
|
||||
if rec.SrcIP != tt.clientHello.SrcIP {
|
||||
t.Errorf("SrcIP = %v, want %v", rec.SrcIP, tt.clientHello.SrcIP)
|
||||
}
|
||||
if rec.SrcPort != tt.clientHello.SrcPort {
|
||||
t.Errorf("SrcPort = %v, want %v", rec.SrcPort, tt.clientHello.SrcPort)
|
||||
}
|
||||
if rec.DstIP != tt.clientHello.DstIP {
|
||||
t.Errorf("DstIP = %v, want %v", rec.DstIP, tt.clientHello.DstIP)
|
||||
}
|
||||
if rec.DstPort != tt.clientHello.DstPort {
|
||||
t.Errorf("DstPort = %v, want %v", rec.DstPort, tt.clientHello.DstPort)
|
||||
}
|
||||
|
||||
// Verify IPMeta fields
|
||||
if rec.IPTTL != tt.clientHello.IPMeta.TTL {
|
||||
t.Errorf("IPTTL = %v, want %v", rec.IPTTL, tt.clientHello.IPMeta.TTL)
|
||||
}
|
||||
if rec.IPTotalLen != tt.clientHello.IPMeta.TotalLength {
|
||||
t.Errorf("IPTotalLen = %v, want %v", rec.IPTotalLen, tt.clientHello.IPMeta.TotalLength)
|
||||
}
|
||||
if rec.IPID != tt.clientHello.IPMeta.IPID {
|
||||
t.Errorf("IPID = %v, want %v", rec.IPID, tt.clientHello.IPMeta.IPID)
|
||||
}
|
||||
if rec.IPDF != tt.clientHello.IPMeta.DF {
|
||||
t.Errorf("IPDF = %v, want %v", rec.IPDF, tt.clientHello.IPMeta.DF)
|
||||
}
|
||||
|
||||
// Verify TCPMeta fields
|
||||
if rec.TCPWindow != tt.clientHello.TCPMeta.WindowSize {
|
||||
t.Errorf("TCPWindow = %v, want %v", rec.TCPWindow, tt.clientHello.TCPMeta.WindowSize)
|
||||
}
|
||||
|
||||
// Verify optional fields (MSS, WindowScale)
|
||||
if tt.clientHello.TCPMeta.MSS != 0 {
|
||||
if rec.TCPMSS == nil {
|
||||
t.Error("TCPMSS should not be nil when MSS != 0")
|
||||
} else if *rec.TCPMSS != tt.clientHello.TCPMeta.MSS {
|
||||
t.Errorf("TCPMSS = %v, want %v", *rec.TCPMSS, tt.clientHello.TCPMeta.MSS)
|
||||
}
|
||||
} else {
|
||||
if rec.TCPMSS != nil {
|
||||
t.Error("TCPMSS should be nil when MSS == 0")
|
||||
}
|
||||
}
|
||||
|
||||
if tt.clientHello.TCPMeta.WindowScale != 0 {
|
||||
if rec.TCPWScale == nil {
|
||||
t.Error("TCPWScale should not be nil when WindowScale != 0")
|
||||
} else if *rec.TCPWScale != tt.clientHello.TCPMeta.WindowScale {
|
||||
t.Errorf("TCPWScale = %v, want %v", *rec.TCPWScale, tt.clientHello.TCPMeta.WindowScale)
|
||||
}
|
||||
} else {
|
||||
if rec.TCPWScale != nil {
|
||||
t.Error("TCPWScale should be nil when WindowScale == 0")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
if rec.JA3Hash != tt.fingerprints.JA3Hash {
|
||||
t.Errorf("JA3Hash = %v, want %v", rec.JA3Hash, tt.fingerprints.JA3Hash)
|
||||
}
|
||||
} else {
|
||||
if rec.JA4 != "" {
|
||||
t.Error("JA4 should be empty when fingerprints is nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Core.Interface != DefaultInterface {
|
||||
t.Errorf("Core.Interface = %v, want %v", cfg.Core.Interface, DefaultInterface)
|
||||
}
|
||||
if len(cfg.Core.ListenPorts) != 1 {
|
||||
t.Errorf("Core.ListenPorts length = %v, want 1", len(cfg.Core.ListenPorts))
|
||||
}
|
||||
if cfg.Core.ListenPorts[0] != DefaultPort {
|
||||
t.Errorf("Core.ListenPorts[0] = %v, want %v", cfg.Core.ListenPorts[0], DefaultPort)
|
||||
}
|
||||
if cfg.Core.BPFFilter != DefaultBPFFilter {
|
||||
t.Errorf("Core.BPFFilter = %v, want %v", cfg.Core.BPFFilter, DefaultBPFFilter)
|
||||
}
|
||||
if cfg.Core.FlowTimeoutSec != DefaultFlowTimeout {
|
||||
t.Errorf("Core.FlowTimeoutSec = %v, want %v", cfg.Core.FlowTimeoutSec, DefaultFlowTimeout)
|
||||
}
|
||||
if len(cfg.Outputs) != 0 {
|
||||
t.Errorf("Outputs length = %v, want 0", len(cfg.Outputs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogRecordConversion(t *testing.T) {
|
||||
// Test that NewLogRecord correctly converts TCPMeta options to comma-separated string
|
||||
clientHello := TLSClientHello{
|
||||
SrcIP: "192.168.1.100",
|
||||
SrcPort: 54321,
|
||||
DstIP: "10.0.0.1",
|
||||
DstPort: 443,
|
||||
TCPMeta: TCPMeta{
|
||||
WindowSize: 65535,
|
||||
MSS: 1460,
|
||||
WindowScale: 7,
|
||||
Options: []string{"MSS", "WS", "SACK", "TS"},
|
||||
},
|
||||
}
|
||||
|
||||
rec := NewLogRecord(clientHello, nil)
|
||||
|
||||
// Verify options are joined with comma
|
||||
expectedOpts := "MSS,WS,SACK,TS"
|
||||
if rec.TCPOptions != expectedOpts {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user