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
|
// 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.
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -156,7 +156,6 @@ 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)
|
||||||
@ -166,22 +165,31 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
|||||||
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
|
||||||
}
|
|
||||||
|
|
||||||
// Try parsing with Ethernet first (for physical interfaces)
|
|
||||||
packet := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default)
|
packet := gopacket.NewPacket(data, layers.LinkTypeEthernet, gopacket.Default)
|
||||||
ipLayer = packet.Layer(layers.LayerTypeIPv4)
|
ipLayer = packet.Layer(layers.LayerTypeIPv4)
|
||||||
if ipLayer == nil {
|
if ipLayer == nil {
|
||||||
@ -189,20 +197,25 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) {
|
|||||||
}
|
}
|
||||||
tcpLayer = packet.Layer(layers.LayerTypeTCP)
|
tcpLayer = packet.Layer(layers.LayerTypeTCP)
|
||||||
|
|
||||||
// If no IP/TCP layer found with Ethernet, try parsing as raw IP
|
// If no IP/TCP layer found with Ethernet, try parsing as raw IP.
|
||||||
// This handles stripped SLL data or other non-Ethernet formats
|
// Use LayerTypeIPv4/IPv6 (not LinkTypeIPv4/IPv6) so gopacket decodes
|
||||||
|
// the payload starting directly from the IP header.
|
||||||
if ipLayer == nil || tcpLayer == nil {
|
if ipLayer == nil || tcpLayer == nil {
|
||||||
// Try parsing as raw IPv4 packet
|
// Detect IP version from first nibble of first byte
|
||||||
rawPacket := gopacket.NewPacket(data, layers.LinkTypeIPv4, gopacket.Default)
|
if len(data) > 0 && (data[0]>>4) == 4 {
|
||||||
|
rawPacket := gopacket.NewPacket(data, layers.LayerTypeIPv4, gopacket.Default)
|
||||||
ipLayer = rawPacket.Layer(layers.LayerTypeIPv4)
|
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 {
|
if ipLayer != nil {
|
||||||
tcpLayer = rawPacket.Layer(layers.LayerTypeTCP)
|
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 {
|
if ipLayer == nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user