Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
Features: - Add ErrorCallback type for UNIX socket connection error reporting - Add WithErrorCallback option for UnixSocketWriter configuration - Add BuilderImpl.WithErrorCallback() for propagating callbacks - Add consecutive failure tracking in processQueue Testing (50+ new tests): - Add integration tests for full pipeline (capture → tlsparse → fingerprint → output) - Add tests for FileWriter.rotate() and Reopen() log rotation - Add tests for cleanupExpiredFlows() and cleanupLoop() in TLS parser - Add tests for extractSNIFromPayload() and extractJA4Hash() helpers - Add tests for config load error paths (invalid YAML, permission denied) - Add tests for capture.Run() error conditions - Add tests for signal handling documentation Documentation: - Update architecture.yml with new fields (LogLevel, TLSClientHello extensions) - Update architecture.yml with Close() methods for Capture and Parser interfaces - Update RPM spec changelog Cleanup: - Remove empty internal/api/ directory Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
431 lines
9.6 KiB
Go
431 lines
9.6 KiB
Go
package capture
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"ja4sentinel/api"
|
|
|
|
"github.com/google/gopacket"
|
|
"github.com/google/gopacket/layers"
|
|
"github.com/google/gopacket/pcap"
|
|
)
|
|
|
|
func TestCaptureImpl_Run_EmptyInterface(t *testing.T) {
|
|
c := New()
|
|
if c == nil {
|
|
t.Fatal("New() returned nil")
|
|
}
|
|
|
|
cfg := api.Config{
|
|
Interface: "",
|
|
ListenPorts: []uint16{443},
|
|
}
|
|
|
|
out := make(chan api.RawPacket, 10)
|
|
err := c.Run(cfg, out)
|
|
|
|
if err == nil {
|
|
t.Error("Run() with empty interface should return error")
|
|
}
|
|
if err.Error() != "interface cannot be empty" {
|
|
t.Errorf("Run() error = %v, want 'interface cannot be empty'", err)
|
|
}
|
|
}
|
|
|
|
func TestCaptureImpl_Run_NonExistentInterface(t *testing.T) {
|
|
c := New()
|
|
if c == nil {
|
|
t.Fatal("New() returned nil")
|
|
}
|
|
|
|
cfg := api.Config{
|
|
Interface: "nonexistent_interface_xyz123",
|
|
ListenPorts: []uint16{443},
|
|
}
|
|
|
|
out := make(chan api.RawPacket, 10)
|
|
err := c.Run(cfg, out)
|
|
|
|
if err == nil {
|
|
t.Error("Run() with non-existent interface should return error")
|
|
}
|
|
}
|
|
|
|
func TestCaptureImpl_Run_InvalidBPFFilter(t *testing.T) {
|
|
// Get a real interface name
|
|
ifaces, err := pcap.FindAllDevs()
|
|
if err != nil || len(ifaces) == 0 {
|
|
t.Skip("No network interfaces available for testing")
|
|
}
|
|
|
|
c := New()
|
|
cfg := api.Config{
|
|
Interface: ifaces[0].Name,
|
|
ListenPorts: []uint16{443},
|
|
BPFFilter: "invalid; rm -rf /", // Invalid characters
|
|
}
|
|
|
|
out := make(chan api.RawPacket, 10)
|
|
err = c.Run(cfg, out)
|
|
|
|
if err == nil {
|
|
t.Error("Run() with invalid BPF filter should return error")
|
|
}
|
|
}
|
|
|
|
func TestCaptureImpl_Run_ChannelFull_DropsPackets(t *testing.T) {
|
|
// This test verifies that when the output channel is full,
|
|
// packets are dropped gracefully (non-blocking write)
|
|
|
|
// We can't easily test the full Run() loop without real interfaces,
|
|
// but we can verify the channel behavior with a small buffer
|
|
out := make(chan api.RawPacket, 1)
|
|
|
|
// Fill the channel
|
|
out <- api.RawPacket{Data: []byte{1, 2, 3}, Timestamp: time.Now().UnixNano()}
|
|
|
|
// Channel should be full now, select default should trigger
|
|
done := make(chan bool)
|
|
go func() {
|
|
select {
|
|
case out <- api.RawPacket{Data: []byte{4, 5, 6}, Timestamp: time.Now().UnixNano()}:
|
|
done <- false // Would block
|
|
default:
|
|
done <- true // Dropped as expected
|
|
}
|
|
}()
|
|
|
|
dropped := <-done
|
|
if !dropped {
|
|
t.Error("Expected packet to be dropped when channel is full")
|
|
}
|
|
}
|
|
|
|
func TestPacketToRawPacket(t *testing.T) {
|
|
t.Run("valid_packet", func(t *testing.T) {
|
|
// Create a simple TCP packet
|
|
eth := layers.Ethernet{
|
|
SrcMAC: []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55},
|
|
DstMAC: []byte{0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB},
|
|
EthernetType: layers.EthernetTypeIPv4,
|
|
}
|
|
ip := layers.IPv4{
|
|
Version: 4,
|
|
TTL: 64,
|
|
Protocol: layers.IPProtocolTCP,
|
|
SrcIP: []byte{192, 168, 1, 1},
|
|
DstIP: []byte{10, 0, 0, 1},
|
|
}
|
|
tcp := layers.TCP{
|
|
SrcPort: 12345,
|
|
DstPort: 443,
|
|
}
|
|
tcp.SetNetworkLayerForChecksum(&ip)
|
|
|
|
buf := gopacket.NewSerializeBuffer()
|
|
opts := gopacket.SerializeOptions{}
|
|
gopacket.SerializeLayers(buf, opts, ð, &ip, &tcp)
|
|
|
|
packet := gopacket.NewPacket(buf.Bytes(), layers.LinkTypeEthernet, gopacket.Default)
|
|
rawPkt := packetToRawPacket(packet)
|
|
|
|
if rawPkt == nil {
|
|
t.Fatal("packetToRawPacket() returned nil for valid packet")
|
|
}
|
|
if len(rawPkt.Data) == 0 {
|
|
t.Error("packetToRawPacket() returned empty data")
|
|
}
|
|
if rawPkt.Timestamp == 0 {
|
|
t.Error("packetToRawPacket() returned zero timestamp")
|
|
}
|
|
})
|
|
|
|
t.Run("empty_packet", func(t *testing.T) {
|
|
// Create packet with no data
|
|
packet := gopacket.NewPacket([]byte{}, layers.LinkTypeEthernet, gopacket.Default)
|
|
rawPkt := packetToRawPacket(packet)
|
|
|
|
if rawPkt != nil {
|
|
t.Error("packetToRawPacket() should return nil for empty packet")
|
|
}
|
|
})
|
|
|
|
t.Run("nil_packet", func(t *testing.T) {
|
|
// packetToRawPacket will panic with nil packet due to Metadata() call
|
|
// This is expected behavior - the function is not designed to handle nil
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("packetToRawPacket() with nil packet should panic")
|
|
}
|
|
}()
|
|
var packet gopacket.Packet
|
|
_ = packetToRawPacket(packet)
|
|
})
|
|
}
|
|
|
|
func TestGetInterfaceNames(t *testing.T) {
|
|
t.Run("empty_list", func(t *testing.T) {
|
|
names := getInterfaceNames([]pcap.Interface{})
|
|
if len(names) != 0 {
|
|
t.Errorf("getInterfaceNames() with empty list = %v, want []", names)
|
|
}
|
|
})
|
|
|
|
t.Run("single_interface", func(t *testing.T) {
|
|
ifaces := []pcap.Interface{
|
|
{Name: "eth0"},
|
|
}
|
|
names := getInterfaceNames(ifaces)
|
|
if len(names) != 1 || names[0] != "eth0" {
|
|
t.Errorf("getInterfaceNames() = %v, want [eth0]", names)
|
|
}
|
|
})
|
|
|
|
t.Run("multiple_interfaces", func(t *testing.T) {
|
|
ifaces := []pcap.Interface{
|
|
{Name: "eth0"},
|
|
{Name: "lo"},
|
|
{Name: "docker0"},
|
|
}
|
|
names := getInterfaceNames(ifaces)
|
|
if len(names) != 3 {
|
|
t.Errorf("getInterfaceNames() returned %d names, want 3", len(names))
|
|
}
|
|
expected := []string{"eth0", "lo", "docker0"}
|
|
for i, name := range names {
|
|
if name != expected[i] {
|
|
t.Errorf("getInterfaceNames()[%d] = %s, want %s", i, name, expected[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateBPFFilter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filter string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "empty filter",
|
|
filter: "",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid simple filter",
|
|
filter: "tcp port 443",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid complex filter",
|
|
filter: "(tcp port 443) or (tcp port 8443)",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "filter with special chars",
|
|
filter: "tcp port 443 and host 192.168.1.1",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "too long filter",
|
|
filter: string(make([]byte, MaxBPFFilterLength+1)),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unbalanced parentheses - extra open",
|
|
filter: "(tcp port 443",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unbalanced parentheses - extra close",
|
|
filter: "tcp port 443)",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid characters - semicolon",
|
|
filter: "tcp port 443; rm -rf /",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid characters - backtick",
|
|
filter: "tcp port `whoami`",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid characters - dollar",
|
|
filter: "tcp port $HOME",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateBPFFilter(tt.filter)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateBPFFilter() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildBPFForPorts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ports []uint16
|
|
want string
|
|
}{
|
|
{
|
|
name: "no ports",
|
|
ports: []uint16{},
|
|
want: "tcp",
|
|
},
|
|
{
|
|
name: "single port",
|
|
ports: []uint16{443},
|
|
want: "(tcp port 443)",
|
|
},
|
|
{
|
|
name: "multiple ports",
|
|
ports: []uint16{443, 8443, 9443},
|
|
want: "(tcp port 443) or (tcp port 8443) or (tcp port 9443)",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := buildBPFForPorts(tt.ports)
|
|
if got != tt.want {
|
|
t.Errorf("buildBPFForPorts() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJoinString(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
parts []string
|
|
sep string
|
|
want string
|
|
}{
|
|
{
|
|
name: "empty slice",
|
|
parts: []string{},
|
|
sep: ") or (",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "single element",
|
|
parts: []string{"tcp port 443"},
|
|
sep: ") or (",
|
|
want: "tcp port 443",
|
|
},
|
|
{
|
|
name: "multiple elements",
|
|
parts: []string{"tcp port 443", "tcp port 8443"},
|
|
sep: ") or (",
|
|
want: "tcp port 443) or (tcp port 8443",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := joinString(tt.parts, tt.sep)
|
|
if got != tt.want {
|
|
t.Errorf("joinString() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewCapture(t *testing.T) {
|
|
c := New()
|
|
if c == nil {
|
|
t.Fatal("New() returned nil")
|
|
}
|
|
if c.snapLen != DefaultSnapLen {
|
|
t.Errorf("snapLen = %d, want %d", c.snapLen, DefaultSnapLen)
|
|
}
|
|
if c.promisc != DefaultPromiscuous {
|
|
t.Errorf("promisc = %v, want %v", c.promisc, DefaultPromiscuous)
|
|
}
|
|
}
|
|
|
|
func TestNewWithSnapLen(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
snapLen int
|
|
wantSnapLen int
|
|
}{
|
|
{
|
|
name: "valid snapLen",
|
|
snapLen: 2048,
|
|
wantSnapLen: 2048,
|
|
},
|
|
{
|
|
name: "zero snapLen uses default",
|
|
snapLen: 0,
|
|
wantSnapLen: DefaultSnapLen,
|
|
},
|
|
{
|
|
name: "negative snapLen uses default",
|
|
snapLen: -100,
|
|
wantSnapLen: DefaultSnapLen,
|
|
},
|
|
{
|
|
name: "too large snapLen uses default",
|
|
snapLen: 100000,
|
|
wantSnapLen: DefaultSnapLen,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := NewWithSnapLen(tt.snapLen)
|
|
if c == nil {
|
|
t.Fatal("NewWithSnapLen() returned nil")
|
|
}
|
|
if c.snapLen != tt.wantSnapLen {
|
|
t.Errorf("snapLen = %d, want %d", c.snapLen, tt.wantSnapLen)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCaptureImpl_Close(t *testing.T) {
|
|
c := New()
|
|
if c == nil {
|
|
t.Fatal("New() returned nil")
|
|
}
|
|
|
|
// Close should not panic on fresh instance
|
|
if err := c.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
// Multiple closes should be safe
|
|
if err := c.Close(); err != nil {
|
|
t.Errorf("Close() second call error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateBPFFilter_BalancedParentheses(t *testing.T) {
|
|
// Test various balanced parentheses scenarios
|
|
validFilters := []string{
|
|
"(tcp port 443)",
|
|
"((tcp port 443))",
|
|
"(tcp port 443) or (tcp port 8443)",
|
|
"((tcp port 443) or (tcp port 8443))",
|
|
"(tcp port 443 and host 1.2.3.4) or (tcp port 8443)",
|
|
}
|
|
|
|
for _, filter := range validFilters {
|
|
t.Run(filter, func(t *testing.T) {
|
|
if err := validateBPFFilter(filter); err != nil {
|
|
t.Errorf("validateBPFFilter(%q) unexpected error = %v", filter, err)
|
|
}
|
|
})
|
|
}
|
|
}
|