From fd5c707af65b1c5d7d51c1694421e9c5e73caf4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 20 Aug 2026 12:48:30 -0600 Subject: [PATCH] feat[backend](alerts): populate per-parent echo count in list --- backend/modules/alerts/domain/alert.go | 1 + .../loganalyzer/repository/analyzer_ch.go | 52 ++++++++++++ .../repository/analyzer_ch_echoes_test.go | 82 +++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 backend/modules/loganalyzer/repository/analyzer_ch_echoes_test.go diff --git a/backend/modules/alerts/domain/alert.go b/backend/modules/alerts/domain/alert.go index 9176c593e..45b9b7e8b 100644 --- a/backend/modules/alerts/domain/alert.go +++ b/backend/modules/alerts/domain/alert.go @@ -137,6 +137,7 @@ type UtmAlert struct { StatusObservation string `json:"statusObservation,omitempty"` Impact *Impact `json:"impact,omitempty"` ImpactScore int `json:"impactScore,omitempty"` + Echoes int64 `json:"echoes,omitempty"` Adversary *Side `json:"adversary,omitempty"` Target *Side `json:"target,omitempty"` LastEvent json.RawMessage `json:"lastEvent,omitempty"` diff --git a/backend/modules/loganalyzer/repository/analyzer_ch.go b/backend/modules/loganalyzer/repository/analyzer_ch.go index 48177da06..e77ddc694 100644 --- a/backend/modules/loganalyzer/repository/analyzer_ch.go +++ b/backend/modules/loganalyzer/repository/analyzer_ch.go @@ -269,9 +269,61 @@ func (r *chAnalyzerRepository) Search(ctx context.Context, req dto.SearchRequest if docs == nil { docs = []json.RawMessage{} } + if req.Dataset == "alerts" { + docs = r.enrichEchoCounts(ctx, scope, docs) + } return &dto.SearchResponse{Data: docs, Total: total}, nil } +func (r *chAnalyzerRepository) enrichEchoCounts(ctx context.Context, scope store.Scope, docs []json.RawMessage) []json.RawMessage { + if len(docs) == 0 { + return docs + } + + parsed := make([]map[string]any, len(docs)) + ids := make([]string, 0, len(docs)) + for i, raw := range docs { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + continue + } + parsed[i] = m + if id, ok := m["id"].(string); ok && id != "" { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return docs + } + + // ponytail: one TopValues per page; fold into a single hand-written SELECT if the extra roundtrip ever shows up in traces. + buckets, err := r.store.TopValues(ctx, scope, "parentId", []store.Filter{{Field: "parentId", Op: store.OpIn, Value: ids}}, len(ids)) + if err != nil { + return docs + } + + counts := make(map[string]int64, len(buckets)) + for _, b := range buckets { + if b.Count > 0 { + counts[b.Key] = b.Count + } + } + + for i, m := range parsed { + if m == nil { + continue + } + id, _ := m["id"].(string) + if c, ok := counts[id]; ok { + m["echoes"] = c + if raw, err := json.Marshal(m); err == nil { + docs[i] = raw + } + } + } + return docs +} + // DataTypes lists the kinds of record a dataset actually holds — o365, // wineventlog and so on. It is what the explorer picks between: the dataset is // the table, the data type is what an analyst thinks of as "which logs". diff --git a/backend/modules/loganalyzer/repository/analyzer_ch_echoes_test.go b/backend/modules/loganalyzer/repository/analyzer_ch_echoes_test.go new file mode 100644 index 000000000..fa9ba0965 --- /dev/null +++ b/backend/modules/loganalyzer/repository/analyzer_ch_echoes_test.go @@ -0,0 +1,82 @@ +package repository + +import ( + "context" + "encoding/json" + "testing" + + "github.com/threatwinds/go-sdk/store" +) + +type echoFakeReader struct { + docs []json.RawMessage + buckets []store.Bucket +} + +func (f *echoFakeReader) DescribeFields(_ context.Context, _ store.Scope) ([]store.Field, error) { + return nil, nil +} +func (f *echoFakeReader) FetchPage(_ context.Context, _ store.Scope, _ []store.Filter, _ store.Page) ([]json.RawMessage, int64, error) { + return f.docs, int64(len(f.docs)), nil +} +func (f *echoFakeReader) TopValues(_ context.Context, _ store.Scope, _ string, _ []store.Filter, _ int) ([]store.Bucket, error) { + return f.buckets, nil +} +func (f *echoFakeReader) Timeline(_ context.Context, _ store.Scope, _ []store.Filter, _ store.Interval) ([]store.Point, error) { + return nil, nil +} +func (f *echoFakeReader) Count(_ context.Context, _ store.Scope, _ []store.Filter) (int64, error) { + return 0, nil +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestEnrichEchoCounts(t *testing.T) { + docs := []json.RawMessage{ + mustJSON(t, map[string]any{"id": "a", "parentId": ""}), + mustJSON(t, map[string]any{"id": "b", "parentId": ""}), + mustJSON(t, map[string]any{"id": "c", "parentId": ""}), + } + buckets := []store.Bucket{ + {Key: "a", Count: 5}, + {Key: "c", Count: 2}, + } + + repo := &chAnalyzerRepository{store: &echoFakeReader{docs: docs, buckets: buckets}} + result := repo.enrichEchoCounts(context.Background(), store.Scope{}, docs) + + for _, raw := range result { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + id := m["id"].(string) + echoes, hasEchoes := m["echoes"] + + switch id { + case "a": + if !hasEchoes { + t.Error("doc a: want echoes field") + } else if int64(echoes.(float64)) != 5 { + t.Errorf("doc a: want echoes=5, got %v", echoes) + } + case "b": + if hasEchoes { + t.Errorf("doc b: want no echoes field, got %v", echoes) + } + case "c": + if !hasEchoes { + t.Error("doc c: want echoes field") + } else if int64(echoes.(float64)) != 2 { + t.Errorf("doc c: want echoes=2, got %v", echoes) + } + } + } +}