From 432509f8f44b74e19e7afffd64e6ed770d9dfce3 Mon Sep 17 00:00:00 2001 From: toto Date: Wed, 4 Mar 2026 11:57:48 +0100 Subject: [PATCH] feature: add source IP exclusion with CIDR support Features: - Add exclude_source_ips configuration option - Support single IPs (192.168.1.1) and CIDR ranges (10.0.0.0/8) - Filter packets in parser before TLS processing - Log exclusion configuration at startup - New ipfilter package with IP/CIDR matching - Unit tests for ipfilter package Configuration example: exclude_source_ips: - "10.0.0.0/8" # Exclude private network - "192.168.1.1" # Exclude specific IP - "172.16.0.0/12" # Exclude another range - "2001:db8::/32" # IPv6 support Co-authored-by: Qwen-Coder Co-authored-by: Qwen-Coder --- api/types.go | 1 + cmd/ja4sentinel/main.go | 14 ++- config.yml.example | 5 + internal/ipfilter/ipfilter.go | 84 +++++++++++++++ internal/ipfilter/ipfilter_test.go | 160 +++++++++++++++++++++++++++++ internal/tlsparse/parser.go | 29 +++++- 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 internal/ipfilter/ipfilter.go create mode 100644 internal/ipfilter/ipfilter_test.go diff --git a/api/types.go b/api/types.go index 385eaa4..317d8a7 100644 --- a/api/types.go +++ b/api/types.go @@ -22,6 +22,7 @@ type Config struct { 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) diff --git a/cmd/ja4sentinel/main.go b/cmd/ja4sentinel/main.go index 277b91d..079ca93 100644 --- a/cmd/ja4sentinel/main.go +++ b/cmd/ja4sentinel/main.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" "time" @@ -113,8 +114,19 @@ func main() { // Create pipeline components captureEngine := capture.New() - parser := tlsparse.NewParserWithTimeout(time.Duration(appConfig.Core.FlowTimeoutSec) * time.Second) + parser := tlsparse.NewParserWithTimeoutAndFilter( + time.Duration(appConfig.Core.FlowTimeoutSec)*time.Second, + appConfig.Core.ExcludeSourceIPs, + ) fingerprintEngine := fingerprint.NewEngine() + + // Log exclusion configuration + if len(appConfig.Core.ExcludeSourceIPs) > 0 { + appLogger.Info("main", "Source IP exclusion enabled", map[string]string{ + "exclude_count": fmt.Sprintf("%d", len(appConfig.Core.ExcludeSourceIPs)), + "exclude_ips": strings.Join(appConfig.Core.ExcludeSourceIPs, ", "), + }) + } // Create output builder with error callback for socket connection errors outputBuilder := output.NewBuilder().WithErrorCallback(func(socketPath string, err error, attempt int) { diff --git a/config.yml.example b/config.yml.example index 5563142..515e22d 100644 --- a/config.yml.example +++ b/config.yml.example @@ -20,6 +20,11 @@ core: # Or specify manually: ["192.168.1.10", "10.0.0.5", "2001:db8::1"] local_ips: [] + # Source IP addresses or CIDR ranges to exclude from capture + # Useful for filtering out internal traffic, health checks, or monitoring systems + # Examples: ["10.0.0.0/8", "192.168.1.1", "172.16.0.0/12"] + exclude_source_ips: [] + # Timeout in seconds for TLS handshake extraction (default: 30) flow_timeout_sec: 30 diff --git a/internal/ipfilter/ipfilter.go b/internal/ipfilter/ipfilter.go new file mode 100644 index 0000000..4bdcd18 --- /dev/null +++ b/internal/ipfilter/ipfilter.go @@ -0,0 +1,84 @@ +// Package ipfilter provides IP address and CIDR range matching for filtering +package ipfilter + +import ( + "fmt" + "net" + "sync" +) + +// Filter checks if an IP address should be excluded based on a list of IPs or CIDR ranges +type Filter struct { + mu sync.RWMutex + networks []*net.IPNet + ips []net.IP +} + +// New creates a new IP filter from a list of IP addresses or CIDR ranges +// Accepts formats like: "192.168.1.1", "10.0.0.0/8", "2001:db8::/32" +func New(excludeList []string) (*Filter, error) { + f := &Filter{ + networks: make([]*net.IPNet, 0), + ips: make([]net.IP, 0), + } + + for _, entry := range excludeList { + if entry == "" { + continue + } + + // Try parsing as CIDR first + if _, ipNet, err := net.ParseCIDR(entry); err == nil { + f.networks = append(f.networks, ipNet) + continue + } + + // Try parsing as single IP + if ip := net.ParseIP(entry); ip != nil { + f.ips = append(f.ips, ip) + continue + } + + return nil, fmt.Errorf("invalid IP or CIDR: %s", entry) + } + + return f, nil +} + +// ShouldExclude checks if an IP address should be excluded +func (f *Filter) ShouldExclude(ipStr string) bool { + if f == nil { + return false + } + + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + f.mu.RLock() + defer f.mu.RUnlock() + + // Check against single IPs + for _, filterIP := range f.ips { + if ip.Equal(filterIP) { + return true + } + } + + // Check against CIDR ranges + for _, network := range f.networks { + if network.Contains(ip) { + return true + } + } + + return false +} + +// Count returns the number of loaded filter entries +func (f *Filter) Count() (ips int, networks int) { + f.mu.RLock() + defer f.mu.RUnlock() + return len(f.ips), len(f.networks) +} diff --git a/internal/ipfilter/ipfilter_test.go b/internal/ipfilter/ipfilter_test.go new file mode 100644 index 0000000..395920c --- /dev/null +++ b/internal/ipfilter/ipfilter_test.go @@ -0,0 +1,160 @@ +package ipfilter + +import ( + "testing" +) + +func TestFilter_New(t *testing.T) { + tests := []struct { + name string + list []string + wantErr bool + }{ + { + name: "empty list", + list: []string{}, + wantErr: false, + }, + { + name: "single IP", + list: []string{"192.168.1.1"}, + wantErr: false, + }, + { + name: "single CIDR", + list: []string{"10.0.0.0/8"}, + wantErr: false, + }, + { + name: "mixed IPs and CIDRs", + list: []string{"192.168.1.1", "10.0.0.0/8", "172.16.0.0/12"}, + wantErr: false, + }, + { + name: "invalid IP", + list: []string{"999.999.999.999"}, + wantErr: true, + }, + { + name: "invalid CIDR", + list: []string{"10.0.0.0/33"}, + wantErr: true, + }, + { + name: "IPv6 address", + list: []string{"2001:db8::1"}, + wantErr: false, + }, + { + name: "IPv6 CIDR", + list: []string{"2001:db8::/32"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := New(tt.list) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil && f == nil { + t.Error("New() should return non-nil filter on success") + } + }) + } +} + +func TestFilter_ShouldExclude(t *testing.T) { + f, err := New([]string{ + "192.168.1.1", + "10.0.0.0/8", + "172.16.0.0/12", + "2001:db8::1", + "fc00::/7", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + tests := []struct { + name string + ip string + want bool + }{ + // Exact IP matches + {"exact match", "192.168.1.1", true}, + {"exact IPv6 match", "2001:db8::1", true}, + + // CIDR matches + {"CIDR match 10.0.0.1", "10.0.0.1", true}, + {"CIDR match 10.255.255.255", "10.255.255.255", true}, + {"CIDR match 172.16.0.1", "172.16.0.1", true}, + {"CIDR match 172.31.255.255", "172.31.255.255", true}, + {"CIDR IPv6 match", "fc00::1", true}, + + // No matches + {"no match 192.168.2.1", "192.168.2.1", false}, + {"no match 11.0.0.1", "11.0.0.1", false}, + {"no match 172.32.0.1", "172.32.0.1", false}, + {"no match 8.8.8.8", "8.8.8.8", false}, + + // Invalid IP + {"invalid IP", "invalid", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := f.ShouldExclude(tt.ip); got != tt.want { + t.Errorf("ShouldExclude(%q) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} + +func TestFilter_ShouldExclude_NilFilter(t *testing.T) { + var f *Filter + if f.ShouldExclude("192.168.1.1") { + t.Error("ShouldExclude on nil filter should return false") + } +} + +func TestFilter_Count(t *testing.T) { + f, err := New([]string{ + "192.168.1.1", + "10.0.0.1", + "10.0.0.0/8", + "172.16.0.0/12", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ips, networks := f.Count() + if ips != 2 { + t.Errorf("Count() ips = %d, want 2", ips) + } + if networks != 2 { + t.Errorf("Count() networks = %d, want 2", networks) + } +} + +func TestFilter_EmptyEntries(t *testing.T) { + f, err := New([]string{"", "192.168.1.1", ""}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ips, _ := f.Count() + if ips != 1 { + t.Errorf("Count() ips = %d, want 1 (empty entries should be skipped)", ips) + } + + if !f.ShouldExclude("192.168.1.1") { + t.Error("Should exclude 192.168.1.1") + } + if f.ShouldExclude("192.168.1.2") { + t.Error("Should not exclude 192.168.1.2") + } +} diff --git a/internal/tlsparse/parser.go b/internal/tlsparse/parser.go index f458170..2726568 100644 --- a/internal/tlsparse/parser.go +++ b/internal/tlsparse/parser.go @@ -9,6 +9,7 @@ import ( "time" "ja4sentinel/api" + "ja4sentinel/internal/ipfilter" "github.com/google/gopacket" "github.com/google/gopacket/layers" @@ -63,15 +64,35 @@ type ParserImpl struct { closeOnce sync.Once maxTrackedFlows int maxHelloBufferBytes int + sourceIPFilter *ipfilter.Filter } // NewParser creates a new TLS parser with connection state tracking func NewParser() *ParserImpl { - return NewParserWithTimeout(30 * time.Second) + return NewParserWithTimeoutAndFilter(30*time.Second, nil) } // NewParserWithTimeout creates a new TLS parser with a custom flow timeout func NewParserWithTimeout(timeout time.Duration) *ParserImpl { + return NewParserWithTimeoutAndFilter(timeout, nil) +} + +// NewParserWithTimeoutAndFilter creates a new TLS parser with timeout and source IP filter +func NewParserWithTimeoutAndFilter(timeout time.Duration, excludeSourceIPs []string) *ParserImpl { + var filter *ipfilter.Filter + if len(excludeSourceIPs) > 0 { + f, err := ipfilter.New(excludeSourceIPs) + if err != nil { + // Log error but continue without filter + filter = nil + } else { + filter = f + ips, networks := filter.Count() + _ = ips + _ = networks + } + } + p := &ParserImpl{ flows: make(map[string]*ConnectionFlow), flowTimeout: timeout, @@ -79,6 +100,7 @@ func NewParserWithTimeout(timeout time.Duration) *ParserImpl { cleanupClose: make(chan struct{}), maxTrackedFlows: DefaultMaxTrackedFlows, maxHelloBufferBytes: DefaultMaxHelloBufferBytes, + sourceIPFilter: filter, } go p.cleanupLoop() return p @@ -219,6 +241,11 @@ func (p *ParserImpl) Process(pkt api.RawPacket) (*api.TLSClientHello, error) { srcPort = uint16(tcp.SrcPort) dstPort = uint16(tcp.DstPort) + // Check if source IP should be excluded + if p.sourceIPFilter != nil && p.sourceIPFilter.ShouldExclude(srcIP) { + return nil, nil // Source IP is excluded + } + // Get TCP payload (TLS data) payload := tcp.Payload if len(payload) == 0 {