// Package capture provides network packet capture functionality for ja4sentinel package capture import ( "fmt" "regexp" "sync" "github.com/google/gopacket" "github.com/google/gopacket/pcap" "ja4sentinel/api" ) // Capture configuration constants const ( // DefaultSnapLen is the default snapshot length for packet capture // Increased from 1600 to 65535 to capture full packets including large TLS handshakes DefaultSnapLen = 65535 // DefaultPromiscuous is the default promiscuous mode setting DefaultPromiscuous = false // MaxBPFFilterLength is the maximum allowed length for BPF filters MaxBPFFilterLength = 1024 ) // validBPFPattern checks if a BPF filter contains only valid characters // This is a basic validation to prevent injection attacks var validBPFPattern = regexp.MustCompile(`^[a-zA-Z0-9\s\(\)\-\_\.\*\+\?\:\=\!\&\|\<\>\[\]\/\@,]+$`) // CaptureImpl implements the capture.Capture interface for packet capture type CaptureImpl struct { handle *pcap.Handle mu sync.Mutex snapLen int promisc bool isClosed bool } // New creates a new capture instance func New() *CaptureImpl { return &CaptureImpl{ snapLen: DefaultSnapLen, promisc: DefaultPromiscuous, } } // NewWithSnapLen creates a new capture instance with custom snapshot length func NewWithSnapLen(snapLen int) *CaptureImpl { if snapLen <= 0 || snapLen > 65535 { snapLen = DefaultSnapLen } return &CaptureImpl{ snapLen: snapLen, promisc: DefaultPromiscuous, } } // Run starts network packet capture according to the configuration func (c *CaptureImpl) Run(cfg api.Config, out chan<- api.RawPacket) error { // Validate interface name (basic check) if cfg.Interface == "" { return fmt.Errorf("interface cannot be empty") } // Find available interfaces to validate the interface exists ifaces, err := pcap.FindAllDevs() if err != nil { return fmt.Errorf("failed to list network interfaces: %w", err) } interfaceFound := false for _, iface := range ifaces { if iface.Name == cfg.Interface { interfaceFound = true break } } if !interfaceFound { return fmt.Errorf("interface %s not found (available: %v)", cfg.Interface, getInterfaceNames(ifaces)) } handle, err := pcap.OpenLive(cfg.Interface, int32(c.snapLen), c.promisc, pcap.BlockForever) if err != nil { return fmt.Errorf("failed to open interface %s: %w", cfg.Interface, err) } c.mu.Lock() c.handle = handle c.mu.Unlock() defer func() { c.mu.Lock() if c.handle != nil && !c.isClosed { c.handle.Close() c.handle = nil } c.mu.Unlock() }() // Build and apply BPF filter bpfFilter := cfg.BPFFilter if bpfFilter == "" { bpfFilter = buildBPFForPorts(cfg.ListenPorts) } // Validate BPF filter before applying if err := validateBPFFilter(bpfFilter); err != nil { return fmt.Errorf("invalid BPF filter: %w", err) } err = handle.SetBPFFilter(bpfFilter) if err != nil { return fmt.Errorf("failed to set BPF filter '%s': %w", bpfFilter, err) } packetSource := gopacket.NewPacketSource(handle, handle.LinkType()) for packet := range packetSource.Packets() { // Convert packet to RawPacket rawPkt := packetToRawPacket(packet) if rawPkt != nil { select { case out <- *rawPkt: // Packet sent successfully default: // Channel full, drop packet (could add metrics here) } } } return nil } // validateBPFFilter performs basic validation of BPF filter strings func validateBPFFilter(filter string) error { if filter == "" { return nil } if len(filter) > MaxBPFFilterLength { return fmt.Errorf("BPF filter too long (max %d characters)", MaxBPFFilterLength) } // Check for potentially dangerous patterns if !validBPFPattern.MatchString(filter) { return fmt.Errorf("BPF filter contains invalid characters") } // Check for unbalanced parentheses openParens := 0 for _, ch := range filter { if ch == '(' { openParens++ } else if ch == ')' { openParens-- if openParens < 0 { return fmt.Errorf("BPF filter has unbalanced parentheses") } } } if openParens != 0 { return fmt.Errorf("BPF filter has unbalanced parentheses") } return nil } // getInterfaceNames extracts interface names from a list of devices func getInterfaceNames(ifaces []pcap.Interface) []string { names := make([]string, len(ifaces)) for i, iface := range ifaces { names[i] = iface.Name } return names } // buildBPFForPorts builds a BPF filter for the specified TCP ports func buildBPFForPorts(ports []uint16) string { if len(ports) == 0 { return "tcp" } filterParts := make([]string, len(ports)) for i, port := range ports { filterParts[i] = fmt.Sprintf("tcp port %d", port) } return "(" + joinString(filterParts, ") or (") + ")" } // joinString joins strings with a separator func joinString(parts []string, sep string) string { if len(parts) == 0 { return "" } result := parts[0] for _, part := range parts[1:] { result += sep + part } return result } // packetToRawPacket converts a gopacket packet to RawPacket func packetToRawPacket(packet gopacket.Packet) *api.RawPacket { data := packet.Data() if len(data) == 0 { return nil } return &api.RawPacket{ Data: data, Timestamp: packet.Metadata().Timestamp.UnixNano(), } } // Close properly closes the capture handle func (c *CaptureImpl) Close() error { c.mu.Lock() defer c.mu.Unlock() if c.handle != nil && !c.isClosed { c.handle.Close() c.handle = nil c.isClosed = true return nil } c.isClosed = true return nil }