Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/modules/alerts/domain/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
52 changes: 52 additions & 0 deletions backend/modules/loganalyzer/repository/analyzer_ch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
82 changes: 82 additions & 0 deletions backend/modules/loganalyzer/repository/analyzer_ch_echoes_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Loading