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:
28
api/types.go
28
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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -156,7 +156,6 @@ 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)
|
||||
@ -166,22 +165,31 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
||||
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)
|
||||
// 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 {
|
||||
@ -189,20 +197,25 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
||||
}
|
||||
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 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 {
|
||||
// Try parsing as raw IPv4 packet
|
||||
rawPacket := gopacket.NewPacket(data, layers.LinkTypeIPv4, gopacket.Default)
|
||||
// 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 {
|
||||
// Try parsing as raw IPv6 packet
|
||||
rawPacket = gopacket.NewPacket(data, layers.LinkTypeIPv6, gopacket.Default)
|
||||
ipLayer = rawPacket.Layer(layers.LayerTypeIPv6)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ipLayer == nil {
|
||||
|
||||
Reference in New Issue
Block a user