Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
New features: - Extract SNI (Server Name Indication) from TLS ClientHello - Extract ALPN (Application-Layer Protocol Negotiation) protocols - Detect TLS version from ClientHello using tlsfingerprint library - Add ConnID field for TCP flow correlation - Add SensorID field for multi-sensor deployments - Add SynToCHMs timing field for behavioral detection - Add AsyncBuffer configuration for output queue sizing Architecture changes: - Remove JA4Hash from LogRecord (JA4 format includes its own hash portions) - Update api.TLSClientHello with new TLS metadata fields - Update api.LogRecord with correlation, TLS, and timing fields - Ensure 100% compliance with architecture.yml specification Tests: - Add unit tests for TLS extension extraction (SNI, ALPN, Version) - Update tests for new LogRecord schema without JA4Hash - Add tests for AsyncBuffer configuration Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
504 lines
11 KiB
Go
504 lines
11 KiB
Go
package output
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"ja4sentinel/api"
|
|
)
|
|
|
|
func TestStdoutWriter(t *testing.T) {
|
|
w := NewStdoutWriter()
|
|
if w == nil {
|
|
t.Fatal("NewStdoutWriter() returned nil")
|
|
}
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "t13d1516h2_test",
|
|
}
|
|
|
|
// Write should not fail (but we can't easily test stdout output)
|
|
err := w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Close should be no-op
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileWriter(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
w, err := NewFileWriter(testFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "t13d1516h2_test",
|
|
}
|
|
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Close the writer to flush
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
// Verify file was created and contains data
|
|
data, err := os.ReadFile(testFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read test file: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("File is empty")
|
|
}
|
|
|
|
// Verify it's valid JSON
|
|
var got api.LogRecord
|
|
if err := json.Unmarshal(data, &got); err != nil {
|
|
t.Errorf("Output is not valid JSON: %v", err)
|
|
}
|
|
|
|
if got.SrcIP != rec.SrcIP {
|
|
t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec.SrcIP)
|
|
}
|
|
}
|
|
|
|
func TestFileWriter_CreatesDirectory(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "subdir", "nested", "test.log")
|
|
|
|
w, err := NewFileWriter(testFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "test",
|
|
}
|
|
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
|
t.Error("File was not created")
|
|
}
|
|
}
|
|
|
|
func TestMultiWriter(t *testing.T) {
|
|
mw := NewMultiWriter()
|
|
if mw == nil {
|
|
t.Fatal("NewMultiWriter() returned nil")
|
|
}
|
|
|
|
// Create a test writer that tracks writes
|
|
var writeCount int
|
|
testWriter := &testWriter{
|
|
writeFunc: func(rec api.LogRecord) error {
|
|
writeCount++
|
|
return nil
|
|
},
|
|
}
|
|
|
|
mw.Add(testWriter)
|
|
mw.Add(NewStdoutWriter())
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
JA4: "test",
|
|
}
|
|
|
|
err := mw.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
if writeCount != 1 {
|
|
t.Errorf("writeCount = %d, want 1", writeCount)
|
|
}
|
|
|
|
// CloseAll should not fail
|
|
if err := mw.CloseAll(); err != nil {
|
|
t.Errorf("CloseAll() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMultiWriter_WriteError(t *testing.T) {
|
|
mw := NewMultiWriter()
|
|
|
|
// Create a writer that always fails
|
|
failWriter := &testWriter{
|
|
writeFunc: func(rec api.LogRecord) error {
|
|
return os.ErrPermission
|
|
},
|
|
}
|
|
|
|
mw.Add(failWriter)
|
|
|
|
rec := api.LogRecord{SrcIP: "192.168.1.1"}
|
|
err := mw.Write(rec)
|
|
|
|
// Should return the last error
|
|
if err != os.ErrPermission {
|
|
t.Errorf("Write() error = %v, want %v", err, os.ErrPermission)
|
|
}
|
|
}
|
|
|
|
func TestBuilder_NewFromConfig(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
tests := []struct {
|
|
name string
|
|
config api.AppConfig
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "empty config defaults to stdout",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "stdout output",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "stdout", Enabled: true},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "disabled output ignored",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "stdout", Enabled: false},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "file output without path fails",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "file", Enabled: true, Params: map[string]string{}},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unix socket without socket_path fails",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "unix_socket", Enabled: true, Params: map[string]string{}},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unknown output type fails",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "unknown", Enabled: true},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unix socket with custom AsyncBuffer",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{
|
|
Type: "unix_socket",
|
|
Enabled: true,
|
|
AsyncBuffer: 5000,
|
|
Params: map[string]string{"socket_path": "test.sock"},
|
|
},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
// Set up paths for tests that need them (only for valid configs)
|
|
if !tt.wantErr {
|
|
for i := range tt.config.Outputs {
|
|
if tt.config.Outputs[i].Type == "file" {
|
|
if tt.config.Outputs[i].Params == nil {
|
|
tt.config.Outputs[i].Params = make(map[string]string)
|
|
}
|
|
tt.config.Outputs[i].Params["path"] = filepath.Join(tmpDir, "test.log")
|
|
}
|
|
if tt.config.Outputs[i].Type == "unix_socket" {
|
|
if tt.config.Outputs[i].Params == nil {
|
|
tt.config.Outputs[i].Params = make(map[string]string)
|
|
}
|
|
tt.config.Outputs[i].Params["socket_path"] = filepath.Join(tmpDir, "test.sock")
|
|
}
|
|
}
|
|
}
|
|
|
|
_, err := builder.NewFromConfig(tt.config)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("NewFromConfig() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriter(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
// Create writer (socket doesn't need to exist yet)
|
|
w, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test",
|
|
}
|
|
|
|
// Write should queue the message (won't fail if socket doesn't exist)
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Logf("Write() error (expected if socket doesn't exist) = %v", err)
|
|
}
|
|
|
|
// Close should clean up properly
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriterWithConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
w, err := NewUnixSocketWriterWithConfig(socketPath, 1*time.Second, 1*time.Second, 100)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w.dialTimeout != 1*time.Second {
|
|
t.Errorf("dialTimeout = %v, want 1s", w.dialTimeout)
|
|
}
|
|
if w.writeTimeout != 1*time.Second {
|
|
t.Errorf("writeTimeout = %v, want 1s", w.writeTimeout)
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriter_CloseTwice(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
w, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
|
|
// First close
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() first error = %v", err)
|
|
}
|
|
|
|
// Second close should be safe (no-op)
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() second error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriter_WriteAfterClose(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
w, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
rec := api.LogRecord{SrcIP: "192.168.1.1"}
|
|
err = w.Write(rec)
|
|
if err == nil {
|
|
t.Error("Write() after Close() should return error")
|
|
}
|
|
}
|
|
|
|
// testWriter is a mock writer for testing
|
|
type testWriter struct {
|
|
writeFunc func(api.LogRecord) error
|
|
closeFunc func() error
|
|
}
|
|
|
|
func (w *testWriter) Write(rec api.LogRecord) error {
|
|
if w.writeFunc != nil {
|
|
return w.writeFunc(rec)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *testWriter) Close() error {
|
|
if w.closeFunc != nil {
|
|
return w.closeFunc()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Test to verify LogRecord JSON serialization
|
|
func TestLogRecordJSONSerialization(t *testing.T) {
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.100",
|
|
SrcPort: 54321,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
IPTTL: 64,
|
|
IPTotalLen: 512,
|
|
IPID: 12345,
|
|
IPDF: true,
|
|
TCPWindow: 65535,
|
|
TCPOptions: "MSS,WS,SACK,TS",
|
|
// New fields per architecture.yml
|
|
ConnID: "flow-abc123",
|
|
SensorID: "sensor-01",
|
|
TLSVersion: "1.3",
|
|
SNI: "example.com",
|
|
ALPN: "h2",
|
|
// Fingerprints - note: JA4Hash is NOT in LogRecord per architecture
|
|
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
|
|
JA3: "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
|
|
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
|
|
Timestamp: time.Now().UnixNano(),
|
|
}
|
|
|
|
data, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Verify it can be unmarshaled
|
|
var got api.LogRecord
|
|
if err := json.Unmarshal(data, &got); err != nil {
|
|
t.Errorf("json.Unmarshal() error = %v", err)
|
|
}
|
|
|
|
// Verify key fields
|
|
if got.SrcIP != rec.SrcIP {
|
|
t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec.SrcIP)
|
|
}
|
|
if got.JA4 != rec.JA4 {
|
|
t.Errorf("JA4 = %v, want %v", got.JA4, rec.JA4)
|
|
}
|
|
// Verify JA4Hash is NOT present (architecture decision)
|
|
// JA4Hash field doesn't exist in LogRecord anymore
|
|
// Verify new fields
|
|
if got.ConnID != rec.ConnID {
|
|
t.Errorf("ConnID = %v, want %v", got.ConnID, rec.ConnID)
|
|
}
|
|
if got.SNI != rec.SNI {
|
|
t.Errorf("SNI = %v, want %v", got.SNI, rec.SNI)
|
|
}
|
|
}
|
|
|
|
// Test to verify optional fields are omitted when empty
|
|
func TestLogRecordOptionalFieldsOmitted(t *testing.T) {
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
// Optional fields not set
|
|
TCPMSS: nil,
|
|
TCPWScale: nil,
|
|
JA3: "",
|
|
JA3Hash: "",
|
|
}
|
|
|
|
data, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Check that optional fields are not present in JSON
|
|
jsonStr := string(data)
|
|
if contains(jsonStr, `"tcp_meta_mss"`) {
|
|
t.Error("tcp_meta_mss should be omitted when nil")
|
|
}
|
|
if contains(jsonStr, `"tcp_meta_window_scale"`) {
|
|
t.Error("tcp_meta_window_scale should be omitted when nil")
|
|
}
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return bytes.Contains([]byte(s), []byte(substr))
|
|
}
|