diff --git a/api/types.go b/api/types.go index 317d8a7..7d63a47 100644 --- a/api/types.go +++ b/api/types.go @@ -18,14 +18,14 @@ type ServiceLog struct { // Config holds basic network and TLS configuration type Config struct { - Interface string `json:"interface"` - ListenPorts []uint16 `json:"listen_ports"` - BPFFilter string `json:"bpf_filter,omitempty"` - LocalIPs []string `json:"local_ips,omitempty"` // Local IPs to monitor (empty = auto-detect, excludes loopback) - ExcludeSourceIPs []string `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 `json:"flow_timeout_sec,omitempty"` // Timeout for TLS handshake extraction (default: 30) - PacketBufferSize int `json:"packet_buffer_size,omitempty"` // Buffer size for packet channel (default: 1000) - LogLevel string `json:"log_level,omitempty"` // Log level: debug, info, warn, error (default: info) + 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 @@ -120,16 +120,16 @@ 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 - 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. + 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 `json:"core"` - Outputs []OutputConfig `json:"outputs"` + Core Config `yaml:"core" json:"core"` + Outputs []OutputConfig `yaml:"outputs" json:"outputs"` } // Loader defines the interface for loading application configuration. diff --git a/internal/config/loader.go b/internal/config/loader.go index 065622b..ad5906f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "os" "strconv" "strings" @@ -104,10 +105,8 @@ func (l *LoaderImpl) loadFromEnv(config api.AppConfig) api.AppConfig { } } - // JA4SENTINEL_LOG_LEVEL - if val := os.Getenv("JA4SENTINEL_LOG_LEVEL"); val != "" { - config.Core.LogLevel = val - } + // Note: JA4SENTINEL_LOG_LEVEL is intentionally NOT loaded from env. + // log_level must be configured exclusively via the YAML config file. return config } @@ -284,46 +283,13 @@ func ToJSON(config api.AppConfig) string { return string(data) } -// isValidIP checks if a string is a valid IP address +// isValidIP checks if a string is a valid IP address using net.ParseIP func isValidIP(ip string) bool { - if ip == "" { - return false - } - // Simple validation: check if it contains only valid IP characters - for _, ch := range ip { - if !((ch >= '0' && ch <= '9') || ch == '.') { - // Could be IPv6 - if ch == ':' { - return true // Accept IPv6 without detailed validation - } - return false - } - } - return true + return net.ParseIP(ip) != nil } -// isValidCIDR checks if a string is a valid CIDR notation +// isValidCIDR checks if a string is a valid CIDR notation using net.ParseCIDR func isValidCIDR(cidr string) bool { - if cidr == "" { - return false - } - parts := strings.Split(cidr, "/") - if len(parts) != 2 { - return false - } - // Check IP part - if !isValidIP(parts[0]) { - return false - } - // Check prefix length - prefix, err := strconv.Atoi(parts[1]) - if err != nil { - return false - } - if strings.Contains(parts[0], ":") { - // IPv6 - return prefix >= 0 && prefix <= 128 - } - // IPv4 - return prefix >= 0 && prefix <= 32 + _, _, err := net.ParseCIDR(cidr) + return err == nil } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 901f3b8..1ef3dac 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -583,14 +583,6 @@ func TestLoadFromEnv_InvalidValues(t *testing.T) { wantErr: true, // Validation error errContains: "flow_timeout_sec must be between", }, - { - name: "invalid_log_level", - env: map[string]string{ - "JA4SENTINEL_LOG_LEVEL": "invalid-level", - }, - wantErr: true, // Validation error - errContains: "log_level must be one of", - }, } for _, tt := range tests { @@ -637,7 +629,6 @@ func TestLoadFromEnv_AllValidValues(t *testing.T) { t.Setenv("JA4SENTINEL_BPF_FILTER", "tcp port 8443") t.Setenv("JA4SENTINEL_FLOW_TIMEOUT", "60") t.Setenv("JA4SENTINEL_PACKET_BUFFER_SIZE", "2000") - t.Setenv("JA4SENTINEL_LOG_LEVEL", "debug") loader := NewLoader("") cfg, err := loader.Load() @@ -661,9 +652,6 @@ func TestLoadFromEnv_AllValidValues(t *testing.T) { if cfg.Core.PacketBufferSize != 2000 { t.Errorf("PacketBufferSize = %d, want 2000", cfg.Core.PacketBufferSize) } - if cfg.Core.LogLevel != "debug" { - t.Errorf("LogLevel = %q, want 'debug'", cfg.Core.LogLevel) - } } // TestValidate_WhitespaceOnlyInterface tests that whitespace-only interface is rejected @@ -826,7 +814,7 @@ outputs: } if len(cfg.Core.ExcludeSourceIPs) != 2 { - t.Errorf("ExcludeSourceIPs length = %d, want 2", len(cfg.Core.ExcludeSourceIPs)) + t.Fatalf("ExcludeSourceIPs length = %d, want 2", len(cfg.Core.ExcludeSourceIPs)) } if cfg.Core.ExcludeSourceIPs[0] != "10.0.0.0/8" { t.Errorf("ExcludeSourceIPs[0] = %q, want '10.0.0.0/8'", cfg.Core.ExcludeSourceIPs[0]) @@ -955,6 +943,8 @@ func TestValidate_ExcludeSourceIPs(t *testing.T) { Core: api.Config{ Interface: "eth0", ListenPorts: []uint16{443}, + FlowTimeoutSec: api.DefaultFlowTimeout, + PacketBufferSize: api.DefaultPacketBuffer, ExcludeSourceIPs: tt.ips, }, Outputs: []api.OutputConfig{ @@ -981,7 +971,8 @@ func TestValidate_ExcludeSourceIPs(t *testing.T) { func TestLoadFromEnv_ExcludeSourceIPs_NotSupported(t *testing.T) { // This test documents that exclude_source_ips is NOT loaded from env // It's only loaded from config file - t.Setenv("JA4SENTINEL_LOG_LEVEL", "debug") + t.Setenv("JA4SENTINEL_INTERFACE", "lo") + t.Setenv("JA4SENTINEL_PORTS", "443") loader := NewLoader("") cfg, err := loader.Load() @@ -994,9 +985,24 @@ func TestLoadFromEnv_ExcludeSourceIPs_NotSupported(t *testing.T) { if len(cfg.Core.ExcludeSourceIPs) != 0 { t.Errorf("ExcludeSourceIPs should be empty from env, got %v", cfg.Core.ExcludeSourceIPs) } +} - // But log_level should be loaded from env - if cfg.Core.LogLevel != "debug" { - t.Errorf("LogLevel = %q, want 'debug'", cfg.Core.LogLevel) +// TestLoadFromEnv_LogLevelIgnored verifies that JA4SENTINEL_LOG_LEVEL env var is NOT honored. +// log_level must be configured exclusively via the YAML config file (architecture requirement). +func TestLoadFromEnv_LogLevelIgnored(t *testing.T) { + t.Setenv("JA4SENTINEL_INTERFACE", "lo") + t.Setenv("JA4SENTINEL_PORTS", "443") + t.Setenv("JA4SENTINEL_LOG_LEVEL", "debug") + + loader := NewLoader("") + cfg, err := loader.Load() + + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // log_level must NOT be overridden by env var; the default ("info") applies + if cfg.Core.LogLevel == "debug" { + t.Error("LogLevel should NOT be set from JA4SENTINEL_LOG_LEVEL env var") } } diff --git a/internal/fingerprint/engine_test.go b/internal/fingerprint/engine_test.go index 481cb8a..23d9be8 100644 --- a/internal/fingerprint/engine_test.go +++ b/internal/fingerprint/engine_test.go @@ -1,6 +1,7 @@ package fingerprint import ( + "strings" "testing" "ja4sentinel/api" @@ -195,8 +196,8 @@ func TestFromClientHello_NilPayload(t *testing.T) { if err == nil { t.Error("FromClientHello() with nil payload should return error") } - if err.Error() != "empty ClientHello payload" { - t.Errorf("FromClientHello() error = %v, want 'empty ClientHello payload'", err) + if !strings.HasPrefix(err.Error(), "empty ClientHello payload") { + t.Errorf("FromClientHello() error = %v, should start with 'empty ClientHello payload'", err) } } diff --git a/internal/tlsparse/parser.go b/internal/tlsparse/parser.go index 8fb7f61..00acb89 100644 --- a/internal/tlsparse/parser.go +++ b/internal/tlsparse/parser.go @@ -156,8 +156,7 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { var ipLayer gopacket.Layer var tcpLayer gopacket.Layer - var data []byte - + // Handle different link types // LinkType 1 = Ethernet, LinkType 101 = Linux SLL (cooked capture) const ( @@ -165,43 +164,57 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { LinkTypeLinuxSLL = 101 SLL_HEADER_LEN = 16 ) - - // Check if this is a Linux SLL packet + + // For Linux SLL (cooked capture), strip the 16-byte SLL header and + // decode directly as raw IP using the protocol type from the SLL header. if pkt.LinkType == LinkTypeLinuxSLL && len(pkt.Data) >= SLL_HEADER_LEN { - // Verify SLL protocol type (bytes 12-13, big-endian) protoType := uint16(pkt.Data[12])<<8 | uint16(pkt.Data[13]) - if protoType == 0x0800 || protoType == 0x86DD { - // Strip SLL header and parse as raw IP - data = pkt.Data[SLL_HEADER_LEN:] - } else { - data = pkt.Data + raw := pkt.Data[SLL_HEADER_LEN:] + switch protoType { + case 0x0800: // IPv4 + pkt4 := gopacket.NewPacket(raw, layers.LayerTypeIPv4, gopacket.Default) + ipLayer = pkt4.Layer(layers.LayerTypeIPv4) + if ipLayer != nil { + tcpLayer = pkt4.Layer(layers.LayerTypeTCP) + } + case 0x86DD: // IPv6 + pkt6 := gopacket.NewPacket(raw, layers.LayerTypeIPv6, gopacket.Default) + ipLayer = pkt6.Layer(layers.LayerTypeIPv6) + if ipLayer != nil { + tcpLayer = pkt6.Layer(layers.LayerTypeTCP) + } + } + if ipLayer == nil { + return nil, nil // Unsupported SLL protocol } } else { - // Ethernet or unknown - use data as-is - data = pkt.Data - } - - // Try parsing with Ethernet first (for physical interfaces) - packet := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default) - ipLayer = packet.Layer(layers.LayerTypeIPv4) - if ipLayer == nil { - ipLayer = packet.Layer(layers.LayerTypeIPv6) - } - tcpLayer = packet.Layer(layers.LayerTypeTCP) - - // If no IP/TCP layer found with Ethernet, try parsing as raw IP - // This handles stripped SLL data or other non-Ethernet formats - if ipLayer == nil || tcpLayer == nil { - // Try parsing as raw IPv4 packet - rawPacket := gopacket.NewPacket(data, layers.LinkTypeIPv4, gopacket.Default) - ipLayer = rawPacket.Layer(layers.LayerTypeIPv4) + // Ethernet or unknown link type: try Ethernet first, then raw IP fallback. + data := pkt.Data + packet := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default) + ipLayer = packet.Layer(layers.LayerTypeIPv4) if ipLayer == nil { - // Try parsing as raw IPv6 packet - rawPacket = gopacket.NewPacket(data, layers.LinkTypeIPv6, gopacket.Default) - ipLayer = rawPacket.Layer(layers.LayerTypeIPv6) + ipLayer = packet.Layer(layers.LayerTypeIPv6) } - if ipLayer != nil { - tcpLayer = rawPacket.Layer(layers.LayerTypeTCP) + tcpLayer = packet.Layer(layers.LayerTypeTCP) + + // If no IP/TCP layer found with Ethernet, try parsing as raw IP. + // Use LayerTypeIPv4/IPv6 (not LinkTypeIPv4/IPv6) so gopacket decodes + // the payload starting directly from the IP header. + if ipLayer == nil || tcpLayer == nil { + // Detect IP version from first nibble of first byte + if len(data) > 0 && (data[0]>>4) == 4 { + rawPacket := gopacket.NewPacket(data, layers.LayerTypeIPv4, gopacket.Default) + ipLayer = rawPacket.Layer(layers.LayerTypeIPv4) + if ipLayer != nil { + tcpLayer = rawPacket.Layer(layers.LayerTypeTCP) + } + } else if len(data) > 0 && (data[0]>>4) == 6 { + rawPacket := gopacket.NewPacket(data, layers.LayerTypeIPv6, gopacket.Default) + ipLayer = rawPacket.Layer(layers.LayerTypeIPv6) + if ipLayer != nil { + tcpLayer = rawPacket.Layer(layers.LayerTypeTCP) + } + } } }