fix: architecture violations and pre-existing test bugs

- Remove JA4SENTINEL_LOG_LEVEL env override (architecture violation: log_level must be YAML-only)
- Add TestLoadFromEnv_LogLevelIgnored test to verify env var is ignored
- Fix yaml struct tags in api.Config/AppConfig/OutputConfig (yaml.v3 ignores json tags)
- Fix isValidIP/isValidCIDR to use net.ParseIP/net.ParseCIDR for proper validation
- Fix SLL packet parsing: use protoType from SLL header to select IPv4/IPv6 decoder
- Fix TestLoadFromFile_ExcludeSourceIPs: t.Errorf → t.Fatalf to avoid nil dereference
- Fix TestFromClientHello_NilPayload: use strings.HasPrefix for error message check
- Fix TestValidate_ExcludeSourceIPs: add required FlowTimeoutSec/PacketBufferSize defaults

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-03-05 09:22:29 +01:00
parent bd45344d19
commit e9e523d8a2
5 changed files with 94 additions and 108 deletions

View File

@ -18,14 +18,14 @@ type ServiceLog struct {
// Config holds basic network and TLS configuration // Config holds basic network and TLS configuration
type Config struct { type Config struct {
Interface string `json:"interface"` Interface string `yaml:"interface" json:"interface"`
ListenPorts []uint16 `json:"listen_ports"` ListenPorts []uint16 `yaml:"listen_ports" json:"listen_ports"`
BPFFilter string `json:"bpf_filter,omitempty"` BPFFilter string `yaml:"bpf_filter" json:"bpf_filter,omitempty"`
LocalIPs []string `json:"local_ips,omitempty"` // Local IPs to monitor (empty = auto-detect, excludes loopback) LocalIPs []string `yaml:"local_ips" 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"]) 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 `json:"flow_timeout_sec,omitempty"` // Timeout for TLS handshake extraction (default: 30) FlowTimeoutSec int `yaml:"flow_timeout_sec" 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) PacketBufferSize int `yaml:"packet_buffer_size" 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) LogLevel string `yaml:"log_level" json:"log_level,omitempty"` // Log level: debug, info, warn, error (default: info)
} }
// IPMeta contains IP metadata for stack fingerprinting // IPMeta contains IP metadata for stack fingerprinting
@ -120,16 +120,16 @@ type LogRecord struct {
// OutputConfig defines configuration for a single log output // OutputConfig defines configuration for a single log output
type OutputConfig struct { type OutputConfig struct {
Type string `json:"type"` // unix_socket, stdout, file, etc. Type string `yaml:"type" json:"type"` // unix_socket, stdout, file, etc.
Enabled bool `json:"enabled"` // whether this output is active Enabled bool `yaml:"enabled" json:"enabled"` // whether this output is active
AsyncBuffer int `json:"async_buffer"` // queue size for async writes (e.g., 5000) AsyncBuffer int `yaml:"async_buffer" json:"async_buffer"` // queue size for async writes (e.g., 5000)
Params map[string]string `json:"params"` // specific parameters like socket_path, path, etc. Params map[string]string `yaml:"params" json:"params"` // specific parameters like socket_path, path, etc.
} }
// AppConfig is the complete ja4sentinel configuration // AppConfig is the complete ja4sentinel configuration
type AppConfig struct { type AppConfig struct {
Core Config `json:"core"` Core Config `yaml:"core" json:"core"`
Outputs []OutputConfig `json:"outputs"` Outputs []OutputConfig `yaml:"outputs" json:"outputs"`
} }
// Loader defines the interface for loading application configuration. // Loader defines the interface for loading application configuration.

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@ -104,10 +105,8 @@ func (l *LoaderImpl) loadFromEnv(config api.AppConfig) api.AppConfig {
} }
} }
// JA4SENTINEL_LOG_LEVEL // Note: JA4SENTINEL_LOG_LEVEL is intentionally NOT loaded from env.
if val := os.Getenv("JA4SENTINEL_LOG_LEVEL"); val != "" { // log_level must be configured exclusively via the YAML config file.
config.Core.LogLevel = val
}
return config return config
} }
@ -284,46 +283,13 @@ func ToJSON(config api.AppConfig) string {
return string(data) 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 { func isValidIP(ip string) bool {
if ip == "" { return net.ParseIP(ip) != nil
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
} }
// 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 { func isValidCIDR(cidr string) bool {
if cidr == "" { _, _, err := net.ParseCIDR(cidr)
return false return err == nil
}
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
} }

View File

@ -583,14 +583,6 @@ func TestLoadFromEnv_InvalidValues(t *testing.T) {
wantErr: true, // Validation error wantErr: true, // Validation error
errContains: "flow_timeout_sec must be between", 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 { 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_BPF_FILTER", "tcp port 8443")
t.Setenv("JA4SENTINEL_FLOW_TIMEOUT", "60") t.Setenv("JA4SENTINEL_FLOW_TIMEOUT", "60")
t.Setenv("JA4SENTINEL_PACKET_BUFFER_SIZE", "2000") t.Setenv("JA4SENTINEL_PACKET_BUFFER_SIZE", "2000")
t.Setenv("JA4SENTINEL_LOG_LEVEL", "debug")
loader := NewLoader("") loader := NewLoader("")
cfg, err := loader.Load() cfg, err := loader.Load()
@ -661,9 +652,6 @@ func TestLoadFromEnv_AllValidValues(t *testing.T) {
if cfg.Core.PacketBufferSize != 2000 { if cfg.Core.PacketBufferSize != 2000 {
t.Errorf("PacketBufferSize = %d, want 2000", cfg.Core.PacketBufferSize) 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 // TestValidate_WhitespaceOnlyInterface tests that whitespace-only interface is rejected
@ -826,7 +814,7 @@ outputs:
} }
if len(cfg.Core.ExcludeSourceIPs) != 2 { 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" { 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]) 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{ Core: api.Config{
Interface: "eth0", Interface: "eth0",
ListenPorts: []uint16{443}, ListenPorts: []uint16{443},
FlowTimeoutSec: api.DefaultFlowTimeout,
PacketBufferSize: api.DefaultPacketBuffer,
ExcludeSourceIPs: tt.ips, ExcludeSourceIPs: tt.ips,
}, },
Outputs: []api.OutputConfig{ Outputs: []api.OutputConfig{
@ -981,7 +971,8 @@ func TestValidate_ExcludeSourceIPs(t *testing.T) {
func TestLoadFromEnv_ExcludeSourceIPs_NotSupported(t *testing.T) { func TestLoadFromEnv_ExcludeSourceIPs_NotSupported(t *testing.T) {
// This test documents that exclude_source_ips is NOT loaded from env // This test documents that exclude_source_ips is NOT loaded from env
// It's only loaded from config file // 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("") loader := NewLoader("")
cfg, err := loader.Load() cfg, err := loader.Load()
@ -994,9 +985,24 @@ func TestLoadFromEnv_ExcludeSourceIPs_NotSupported(t *testing.T) {
if len(cfg.Core.ExcludeSourceIPs) != 0 { if len(cfg.Core.ExcludeSourceIPs) != 0 {
t.Errorf("ExcludeSourceIPs should be empty from env, got %v", cfg.Core.ExcludeSourceIPs) t.Errorf("ExcludeSourceIPs should be empty from env, got %v", cfg.Core.ExcludeSourceIPs)
} }
}
// But log_level should be loaded from env // TestLoadFromEnv_LogLevelIgnored verifies that JA4SENTINEL_LOG_LEVEL env var is NOT honored.
if cfg.Core.LogLevel != "debug" { // log_level must be configured exclusively via the YAML config file (architecture requirement).
t.Errorf("LogLevel = %q, want 'debug'", cfg.Core.LogLevel) 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")
} }
} }

View File

@ -1,6 +1,7 @@
package fingerprint package fingerprint
import ( import (
"strings"
"testing" "testing"
"ja4sentinel/api" "ja4sentinel/api"
@ -195,8 +196,8 @@ func TestFromClientHello_NilPayload(t *testing.T) {
if err == nil { if err == nil {
t.Error("FromClientHello() with nil payload should return error") t.Error("FromClientHello() with nil payload should return error")
} }
if err.Error() != "empty ClientHello payload" { if !strings.HasPrefix(err.Error(), "empty ClientHello payload") {
t.Errorf("FromClientHello() error = %v, want 'empty ClientHello payload'", err) t.Errorf("FromClientHello() error = %v, should start with 'empty ClientHello payload'", err)
} }
} }

View File

@ -156,8 +156,7 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
var ipLayer gopacket.Layer var ipLayer gopacket.Layer
var tcpLayer gopacket.Layer var tcpLayer gopacket.Layer
var data []byte
// Handle different link types // Handle different link types
// LinkType 1 = Ethernet, LinkType 101 = Linux SLL (cooked capture) // LinkType 1 = Ethernet, LinkType 101 = Linux SLL (cooked capture)
const ( const (
@ -165,43 +164,57 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
LinkTypeLinuxSLL = 101 LinkTypeLinuxSLL = 101
SLL_HEADER_LEN = 16 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 { 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]) protoType := uint16(pkt.Data[12])<<8 | uint16(pkt.Data[13])
if protoType == 0x0800 || protoType == 0x86DD { raw := pkt.Data[SLL_HEADER_LEN:]
// Strip SLL header and parse as raw IP switch protoType {
data = pkt.Data[SLL_HEADER_LEN:] case 0x0800: // IPv4
} else { pkt4 := gopacket.NewPacket(raw, layers.LayerTypeIPv4, gopacket.Default)
data = pkt.Data 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 { } else {
// Ethernet or unknown - use data as-is // Ethernet or unknown link type: try Ethernet first, then raw IP fallback.
data = pkt.Data data := pkt.Data
} packet := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default)
ipLayer = packet.Layer(layers.LayerTypeIPv4)
// 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)
if ipLayer == nil { if ipLayer == nil {
// Try parsing as raw IPv6 packet ipLayer = packet.Layer(layers.LayerTypeIPv6)
rawPacket = gopacket.NewPacket(data, layers.LinkTypeIPv6, gopacket.Default)
ipLayer = rawPacket.Layer(layers.LayerTypeIPv6)
} }
if ipLayer != nil { tcpLayer = packet.Layer(layers.LayerTypeTCP)
tcpLayer = rawPacket.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)
}
}
} }
} }