fix: renforcer corrélation A/B et sorties stdout/fichier
Co-authored-by: aider (openrouter/openai/gpt-5.3-codex) <aider@aider.chat>
This commit is contained in:
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
@ -180,7 +181,7 @@ func (s *UnixSocketSource) readEvents(ctx context.Context, conn net.Conn, eventC
|
||||
}
|
||||
|
||||
// Debug: log raw events
|
||||
s.logger.Debugf("event received: source=%s src_ip=%s src_port=%d",
|
||||
s.logger.Debugf("event received: source=%s src_ip=%s src_port=%d",
|
||||
event.Source, event.SrcIP, event.SrcPort)
|
||||
|
||||
select {
|
||||
@ -191,6 +192,21 @@ func (s *UnixSocketSource) readEvents(ctx context.Context, conn net.Conn, eventC
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSource(sourceType string, headers map[string]string) domain.EventSource {
|
||||
switch strings.ToLower(strings.TrimSpace(sourceType)) {
|
||||
case "a", "apache", "http":
|
||||
return domain.SourceA
|
||||
case "b", "network", "net":
|
||||
return domain.SourceB
|
||||
default:
|
||||
// fallback compat
|
||||
if len(headers) > 0 {
|
||||
return domain.SourceA
|
||||
}
|
||||
return domain.SourceB
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
@ -198,12 +214,29 @@ func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, er
|
||||
}
|
||||
|
||||
event := &domain.NormalizedEvent{
|
||||
Raw: raw,
|
||||
Extra: make(map[string]any),
|
||||
Raw: raw,
|
||||
Extra: make(map[string]any),
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
|
||||
// Extract headers (header_* fields) first
|
||||
for k, v := range raw {
|
||||
if strings.HasPrefix(k, "header_") {
|
||||
if sv, ok := v.(string); ok {
|
||||
event.Headers[k[7:]] = sv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve source first (strict timestamp logic depends on source)
|
||||
event.Source = resolveSource(sourceType, event.Headers)
|
||||
|
||||
// Extract and validate src_ip
|
||||
if v, ok := getString(raw, "src_ip"); ok {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil, fmt.Errorf("src_ip cannot be empty")
|
||||
}
|
||||
event.SrcIP = v
|
||||
} else {
|
||||
return nil, fmt.Errorf("missing required field: src_ip")
|
||||
@ -221,7 +254,7 @@ func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, er
|
||||
|
||||
// Extract dst_ip (optional)
|
||||
if v, ok := getString(raw, "dst_ip"); ok {
|
||||
event.DstIP = v
|
||||
event.DstIP = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// Extract dst_port (optional)
|
||||
@ -232,50 +265,23 @@ func parseJSONEvent(data []byte, sourceType string) (*domain.NormalizedEvent, er
|
||||
event.DstPort = v
|
||||
}
|
||||
|
||||
// Extract timestamp - try different fields
|
||||
if ts, ok := getInt64(raw, "timestamp"); ok {
|
||||
// Extract timestamp based on source contract
|
||||
switch event.Source {
|
||||
case domain.SourceA:
|
||||
ts, ok := getInt64(raw, "timestamp")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required numeric field: timestamp for source A")
|
||||
}
|
||||
// Assume nanoseconds
|
||||
event.Timestamp = time.Unix(0, ts)
|
||||
} else if tsStr, ok := getString(raw, "time"); ok {
|
||||
if t, err := time.Parse(time.RFC3339, tsStr); err == nil {
|
||||
event.Timestamp = t
|
||||
}
|
||||
} else if tsStr, ok := getString(raw, "timestamp"); ok {
|
||||
if t, err := time.Parse(time.RFC3339, tsStr); err == nil {
|
||||
event.Timestamp = t
|
||||
}
|
||||
}
|
||||
|
||||
if event.Timestamp.IsZero() {
|
||||
case domain.SourceB:
|
||||
// For network source, always use local reception time
|
||||
event.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
// Extract headers (header_* fields)
|
||||
event.Headers = make(map[string]string)
|
||||
for k, v := range raw {
|
||||
if len(k) > 7 && k[:7] == "header_" {
|
||||
if sv, ok := v.(string); ok {
|
||||
event.Headers[k[7:]] = sv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine source based on explicit config or fallback to heuristic
|
||||
switch sourceType {
|
||||
case "A", "a", "apache", "http":
|
||||
event.Source = domain.SourceA
|
||||
case "B", "b", "network", "net":
|
||||
event.Source = domain.SourceB
|
||||
default:
|
||||
// Fallback to heuristic detection for backward compatibility
|
||||
if len(event.Headers) > 0 {
|
||||
event.Source = domain.SourceA
|
||||
} else {
|
||||
event.Source = domain.SourceB
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported source type: %s", event.Source)
|
||||
}
|
||||
|
||||
// Extra fields (single pass)
|
||||
// Extra fields
|
||||
knownFields := map[string]bool{
|
||||
"src_ip": true, "src_port": true, "dst_ip": true, "dst_port": true,
|
||||
"timestamp": true, "time": true,
|
||||
@ -306,6 +312,9 @@ func getInt(m map[string]any, key string) (int, bool) {
|
||||
if v, ok := m[key]; ok {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
if math.Trunc(val) != val {
|
||||
return 0, false
|
||||
}
|
||||
return int(val), true
|
||||
case int:
|
||||
return val, true
|
||||
@ -324,6 +333,9 @@ func getInt64(m map[string]any, key string) (int64, bool) {
|
||||
if v, ok := m[key]; ok {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
if math.Trunc(val) != val {
|
||||
return 0, false
|
||||
}
|
||||
return int64(val), true
|
||||
case int:
|
||||
return int64(val), true
|
||||
|
||||
@ -41,6 +41,10 @@ func TestParseJSONEvent_Apache(t *testing.T) {
|
||||
if event.Source != domain.SourceA {
|
||||
t.Errorf("expected source A, got %s", event.Source)
|
||||
}
|
||||
expectedTs := time.Unix(0, 1704110400000000000)
|
||||
if !event.Timestamp.Equal(expectedTs) {
|
||||
t.Errorf("expected timestamp %v, got %v", expectedTs, event.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONEvent_Network(t *testing.T) {
|
||||
@ -49,12 +53,15 @@ func TestParseJSONEvent_Network(t *testing.T) {
|
||||
"src_port": 8080,
|
||||
"dst_ip": "10.0.0.1",
|
||||
"dst_port": 443,
|
||||
"timestamp": 1704110400000000000,
|
||||
"ja3": "abc123def456",
|
||||
"ja4": "xyz789",
|
||||
"tcp_meta_flags": "SYN"
|
||||
}`)
|
||||
|
||||
before := time.Now()
|
||||
event, err := parseJSONEvent(data, "B")
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@ -68,6 +75,9 @@ func TestParseJSONEvent_Network(t *testing.T) {
|
||||
if event.Source != domain.SourceB {
|
||||
t.Errorf("expected source B, got %s", event.Source)
|
||||
}
|
||||
if event.Timestamp.Before(before.Add(-2*time.Second)) || event.Timestamp.After(after.Add(2*time.Second)) {
|
||||
t.Errorf("expected network timestamp near now, got %v", event.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONEvent_InvalidJSON(t *testing.T) {
|
||||
@ -88,21 +98,35 @@ func TestParseJSONEvent_MissingFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONEvent_StringTimestamp(t *testing.T) {
|
||||
func TestParseJSONEvent_SourceARequiresNumericTimestamp(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"src_ip": "192.168.1.1",
|
||||
"src_port": 8080,
|
||||
"time": "2024-01-01T12:00:00Z"
|
||||
}`)
|
||||
|
||||
event, err := parseJSONEvent(data, "")
|
||||
_, err := parseJSONEvent(data, "A")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for source A without numeric timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONEvent_SourceBIgnoresPayloadTimestamp(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"src_ip": "192.168.1.1",
|
||||
"src_port": 8080,
|
||||
"timestamp": 1
|
||||
}`)
|
||||
|
||||
before := time.Now()
|
||||
event, err := parseJSONEvent(data, "B")
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expected := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
if !event.Timestamp.Equal(expected) {
|
||||
t.Errorf("expected timestamp %v, got %v", expected, event.Timestamp)
|
||||
if event.Timestamp.Before(before.Add(-2*time.Second)) || event.Timestamp.After(after.Add(2*time.Second)) {
|
||||
t.Errorf("expected source B timestamp near now, got %v", event.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,40 +138,40 @@ func TestParseJSONEvent_ExplicitSourceType(t *testing.T) {
|
||||
expected domain.EventSource
|
||||
}{
|
||||
{
|
||||
name: "explicit A",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
name: "explicit A",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "timestamp": 1704110400000000000}`,
|
||||
sourceType: "A",
|
||||
expected: domain.SourceA,
|
||||
expected: domain.SourceA,
|
||||
},
|
||||
{
|
||||
name: "explicit B",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
name: "explicit B",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
sourceType: "B",
|
||||
expected: domain.SourceB,
|
||||
expected: domain.SourceB,
|
||||
},
|
||||
{
|
||||
name: "explicit apache",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
name: "explicit apache",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "timestamp": 1704110400000000000}`,
|
||||
sourceType: "apache",
|
||||
expected: domain.SourceA,
|
||||
expected: domain.SourceA,
|
||||
},
|
||||
{
|
||||
name: "explicit network",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
name: "explicit network",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
sourceType: "network",
|
||||
expected: domain.SourceB,
|
||||
expected: domain.SourceB,
|
||||
},
|
||||
{
|
||||
name: "auto-detect A with headers",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "header_host": "example.com"}`,
|
||||
name: "auto-detect A with headers",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "timestamp": 1704110400000000000, "header_host": "example.com"}`,
|
||||
sourceType: "",
|
||||
expected: domain.SourceA,
|
||||
expected: domain.SourceA,
|
||||
},
|
||||
{
|
||||
name: "auto-detect B without headers",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "ja3": "abc"}`,
|
||||
name: "auto-detect B without headers",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "ja3": "abc"}`,
|
||||
sourceType: "",
|
||||
expected: domain.SourceB,
|
||||
expected: domain.SourceB,
|
||||
},
|
||||
}
|
||||
|
||||
@ -241,7 +265,7 @@ func TestGetInt(t *testing.T) {
|
||||
expected int
|
||||
ok bool
|
||||
}{
|
||||
{"float", 42, true},
|
||||
{"float", 0, false},
|
||||
{"int", 42, true},
|
||||
{"int64", 42, true},
|
||||
{"string", 42, true},
|
||||
@ -278,7 +302,7 @@ func TestGetInt64(t *testing.T) {
|
||||
expected int64
|
||||
ok bool
|
||||
}{
|
||||
{"float", 42, true},
|
||||
{"float", 0, false},
|
||||
{"int", 42, true},
|
||||
{"int64", 42, true},
|
||||
{"string", 42, true},
|
||||
@ -302,45 +326,52 @@ func TestGetInt64(t *testing.T) {
|
||||
|
||||
func TestParseJSONEvent_PortValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantErr bool
|
||||
name string
|
||||
data string
|
||||
sourceType string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid src_port",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
wantErr: false,
|
||||
name: "valid src_port",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080}`,
|
||||
sourceType: "B",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "src_port zero",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 0}`,
|
||||
wantErr: true,
|
||||
name: "src_port zero",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 0}`,
|
||||
sourceType: "B",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "src_port negative",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": -1}`,
|
||||
wantErr: true,
|
||||
name: "src_port negative",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": -1}`,
|
||||
sourceType: "B",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "src_port too high",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 70000}`,
|
||||
wantErr: true,
|
||||
name: "src_port too high",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 70000}`,
|
||||
sourceType: "B",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid dst_port zero",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "dst_port": 0}`,
|
||||
wantErr: false,
|
||||
name: "valid dst_port zero",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "dst_port": 0}`,
|
||||
sourceType: "B",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "dst_port too high",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "dst_port": 70000}`,
|
||||
wantErr: true,
|
||||
name: "dst_port too high",
|
||||
data: `{"src_ip": "192.168.1.1", "src_port": 8080, "dst_port": 70000}`,
|
||||
sourceType: "B",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseJSONEvent([]byte(tt.data), "")
|
||||
_, err := parseJSONEvent([]byte(tt.data), tt.sourceType)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseJSONEvent() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
@ -350,12 +381,12 @@ func TestParseJSONEvent_PortValidation(t *testing.T) {
|
||||
|
||||
func TestParseJSONEvent_TimestampFallback(t *testing.T) {
|
||||
data := []byte(`{"src_ip": "192.168.1.1", "src_port": 8080}`)
|
||||
event, err := parseJSONEvent(data, "")
|
||||
event, err := parseJSONEvent(data, "B")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should fallback to current time if no timestamp provided
|
||||
// For source B, timestamp is reception time
|
||||
if event.Timestamp.IsZero() {
|
||||
t.Error("expected non-zero timestamp")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user