fix: renforcer corrélation A/B et sorties stdout/fichier
Some checks failed
Build and Test / test (push) Has been cancelled
Build and Test / build (push) Has been cancelled
Build and Test / docker (push) Has been cancelled

Co-authored-by: aider (openrouter/openai/gpt-5.3-codex) <aider@aider.chat>
This commit is contained in:
Jacquin Antoine
2026-03-01 12:10:17 +01:00
parent d3436f6245
commit 27c7659397
13 changed files with 441 additions and 259 deletions

View File

@ -2,6 +2,7 @@ package domain
import (
"encoding/json"
"reflect"
"time"
)
@ -22,7 +23,7 @@ type CorrelatedLog struct {
func (c CorrelatedLog) MarshalJSON() ([]byte, error) {
// Create a flat map with all fields
flat := make(map[string]any)
// Add core fields
flat["timestamp"] = c.Timestamp
flat["src_ip"] = c.SrcIP
@ -37,12 +38,24 @@ func (c CorrelatedLog) MarshalJSON() ([]byte, error) {
if c.OrphanSide != "" {
flat["orphan_side"] = c.OrphanSide
}
// Merge additional fields
// Merge additional fields while preserving reserved keys
reservedKeys := map[string]struct{}{
"timestamp": {},
"src_ip": {},
"src_port": {},
"dst_ip": {},
"dst_port": {},
"correlated": {},
"orphan_side": {},
}
for k, v := range c.Fields {
if _, reserved := reservedKeys[k]; reserved {
continue
}
flat[k] = v
}
return json.Marshal(flat)
}
@ -89,13 +102,28 @@ func extractFields(e *NormalizedEvent) map[string]any {
func mergeFields(a, b *NormalizedEvent) map[string]any {
result := make(map[string]any)
// Merge fields from both events
// Start with A fields
for k, v := range a.Raw {
result[k] = v
}
// Merge B fields with collision handling
for k, v := range b.Raw {
if existing, exists := result[k]; exists {
if reflect.DeepEqual(existing, v) {
continue
}
// Collision with different values: keep both with prefixes
delete(result, k)
result["a_"+k] = existing
result["b_"+k] = v
continue
}
result[k] = v
}
return result
}

View File

@ -96,14 +96,11 @@ func (s *CorrelationService) ProcessEvent(event *NormalizedEvent) []CorrelatedLo
// Check buffer overflow before adding
if s.isBufferFull(event.Source) {
// Buffer full, drop event or emit as orphan
s.logger.Warnf("buffer full, dropping event: source=%s src_ip=%s src_port=%d",
s.logger.Warnf("buffer full, dropping event: source=%s src_ip=%s src_port=%d",
event.Source, event.SrcIP, event.SrcPort)
if event.Source == SourceA && s.config.ApacheAlwaysEmit {
return []CorrelatedLog{NewCorrelatedLogFromEvent(event, "A")}
}
if event.Source == SourceB && s.config.NetworkEmit {
return []CorrelatedLog{NewCorrelatedLogFromEvent(event, "B")}
}
return nil
}
@ -123,7 +120,7 @@ func (s *CorrelationService) ProcessEvent(event *NormalizedEvent) []CorrelatedLo
if shouldBuffer {
s.addEvent(event)
s.logger.Debugf("event buffered: source=%s src_ip=%s src_port=%d buffer_size=%d",
s.logger.Debugf("event buffered: source=%s src_ip=%s src_port=%d buffer_size=%d",
event.Source, event.SrcIP, event.SrcPort, s.getBufferSize(event.Source))
}
@ -158,7 +155,7 @@ func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]Correlate
return s.eventsMatch(event, other)
}); bEvent != nil {
correlated := NewCorrelatedLog(event, bEvent)
s.logger.Debugf("correlation found: A(src_ip=%s src_port=%d) + B(src_ip=%s src_port=%d)",
s.logger.Debugf("correlation found: A(src_ip=%s src_port=%d) + B(src_ip=%s src_port=%d)",
event.SrcIP, event.SrcPort, bEvent.SrcIP, bEvent.SrcPort)
return []CorrelatedLog{correlated}, false
}
@ -182,19 +179,12 @@ func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]Correlate
return s.eventsMatch(other, event)
}); aEvent != nil {
correlated := NewCorrelatedLog(aEvent, event)
s.logger.Debugf("correlation found: A(src_ip=%s src_port=%d) + B(src_ip=%s src_port=%d)",
s.logger.Debugf("correlation found: A(src_ip=%s src_port=%d) + B(src_ip=%s src_port=%d)",
aEvent.SrcIP, aEvent.SrcPort, event.SrcIP, event.SrcPort)
return []CorrelatedLog{correlated}, false
}
// No match found - orphan B event (not emitted by default)
if s.config.NetworkEmit {
orphan := NewCorrelatedLogFromEvent(event, "B")
s.logger.Warnf("orphan B event (no A match): src_ip=%s src_port=%d", event.SrcIP, event.SrcPort)
return []CorrelatedLog{orphan}, false
}
// Keep in buffer for potential future match
// Never emit B alone. Keep in buffer for potential future match.
return nil, true
}
@ -231,20 +221,17 @@ func (s *CorrelationService) cleanExpired() {
// cleanBuffer removes expired events from a buffer.
func (s *CorrelationService) cleanBuffer(buffer *eventBuffer, pending map[string][]*list.Element, cutoff time.Time) {
for elem := buffer.events.Front(); elem != nil; {
next := elem.Next()
event := elem.Value.(*NormalizedEvent)
if event.Timestamp.Before(cutoff) {
next := elem.Next()
key := event.CorrelationKey()
buffer.events.Remove(elem)
pending[key] = removeElementFromSlice(pending[key], elem)
if len(pending[key]) == 0 {
delete(pending, key)
}
elem = next
continue
}
// Events are inserted in arrival order; once we hit a non-expired event, stop.
break
elem = next
}
}
@ -307,14 +294,7 @@ func (s *CorrelationService) Flush() []CorrelatedLog {
}
}
// Emit remaining B events as orphans only if explicitly enabled
if s.config.NetworkEmit {
for elem := s.bufferB.events.Front(); elem != nil; elem = elem.Next() {
event := elem.Value.(*NormalizedEvent)
orphan := NewCorrelatedLogFromEvent(event, "B")
results = append(results, orphan)
}
}
// Never emit remaining B events alone.
// Clear buffers
s.bufferA.events.Init()

View File

@ -193,7 +193,6 @@ func TestCorrelationService_FlushWithEvents(t *testing.T) {
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
timeProvider := &mockTimeProvider{now: now}
// Flush only emits events if ApacheAlwaysEmit and NetworkEmit are true
config := CorrelationConfig{
TimeWindow: time.Second,
ApacheAlwaysEmit: true,
@ -201,7 +200,6 @@ func TestCorrelationService_FlushWithEvents(t *testing.T) {
}
svc := NewCorrelationService(config, timeProvider)
// We need to bypass the normal ProcessEvent logic to get events into buffers
// Add events directly to buffers for testing Flush
keyA := "192.168.1.1:8080"
keyB := "192.168.1.2:9090"
@ -219,7 +217,6 @@ func TestCorrelationService_FlushWithEvents(t *testing.T) {
SrcPort: 9090,
}
// Manually add to buffers (simulating events that couldn't be matched)
elemA := svc.bufferA.events.PushBack(apacheEvent)
svc.pendingA[keyA] = append(svc.pendingA[keyA], elemA)
@ -227,8 +224,11 @@ func TestCorrelationService_FlushWithEvents(t *testing.T) {
svc.pendingB[keyB] = append(svc.pendingB[keyB], elemB)
flushed := svc.Flush()
if len(flushed) != 2 {
t.Errorf("expected 2 flushed events, got %d", len(flushed))
if len(flushed) != 1 {
t.Errorf("expected 1 flushed event (A only), got %d", len(flushed))
}
if len(flushed) == 1 && flushed[0].OrphanSide != "A" {
t.Errorf("expected orphan side A, got %s", flushed[0].OrphanSide)
}
// Verify buffers are cleared
@ -262,7 +262,7 @@ func TestCorrelationService_BufferOverflow(t *testing.T) {
svc.ProcessEvent(event)
}
// Buffer full, next event should be dropped (not emitted since ApacheAlwaysEmit=false but buffer full)
// Buffer full, next event should be dropped (not emitted since ApacheAlwaysEmit=false)
overflowEvent := &NormalizedEvent{
Source: SourceA,
Timestamp: now,
@ -340,3 +340,33 @@ func TestCorrelationService_DifferentSourceTypes(t *testing.T) {
t.Error("expected correlated result")
}
}
func TestCorrelationService_NetworkEmitTrue_DoesNotEmitBAlone(t *testing.T) {
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
timeProvider := &mockTimeProvider{now: now}
config := CorrelationConfig{
TimeWindow: time.Second,
ApacheAlwaysEmit: false,
NetworkEmit: true,
}
svc := NewCorrelationService(config, timeProvider)
networkEvent := &NormalizedEvent{
Source: SourceB,
Timestamp: now,
SrcIP: "10.10.10.10",
SrcPort: 5555,
}
results := svc.ProcessEvent(networkEvent)
if len(results) != 0 {
t.Errorf("expected 0 immediate results for orphan B, got %d", len(results))
}
flushed := svc.Flush()
if len(flushed) != 0 {
t.Errorf("expected 0 flushed orphan B events, got %d", len(flushed))
}
}