package detector import ( "context" "crypto/sha256" "encoding/hex" "fmt" "log" "strconv" "strings" "time" "example.com/siem-greenfield/internal/clickhouse" "example.com/siem-greenfield/internal/config" "example.com/siem-greenfield/internal/postgres" ) type rule struct { name, severity string eventCode uint32 score float64 query func(time.Time, time.Time, string) string summary func(map[string]any) string } func Run(ctx context.Context, cfg config.Config) error { pg, e := postgres.Open(ctx, cfg.PostgresURL) if e != nil { return e } defer pg.Close() ch := clickhouse.New(cfg) ticker := time.NewTicker(cfg.DetectorInterval) defer ticker.Stop() run := func() { if e := runAll(ctx, cfg, pg, ch); e != nil { log.Printf("detector cycle: %v", e) } } run() for { select { case <-ctx.Done(): return nil case <-ticker.C: run() } } } func runAll(ctx context.Context, cfg config.Config, pg *postgres.Store, ch *clickhouse.Client) error { end := time.Now().UTC() start := end.Add(-cfg.DetectorLookback) for _, r := range rules() { q := r.query(start, end, cfg.TenantID) rows, e := ch.QueryJSON(ctx, q) if e != nil { return fmt.Errorf("%s: %w", r.name, e) } for _, row := range rows { host := str(row["host_name"]) user := str(row["user_name"]) ip := str(row["source_ip"]) workstation := str(row["workstation"]) count := int64(num(row["cnt"])) ws := timeVal(row["window_start"], start) we := timeVal(row["window_end"], end) fp := fingerprint(r.name, host, user, ip, workstation, strconv.FormatInt(ws.Unix()/300, 10)) d := postgres.Detection{Fingerprint: fp, RuleName: r.name, Severity: r.severity, Hostname: host, UserName: user, SourceIP: ip, Workstation: workstation, EventCode: r.eventCode, Score: r.score, WindowStart: ws, WindowEnd: we, Summary: r.summary(row), Count: max64(1, count)} if e := pg.UpsertDetection(ctx, d, cfg.TenantID); e != nil { return e } } } return nil } func rules() []rule { return []rule{ {name: "audit_log_cleared", severity: "critical", eventCode: 1102, score: 9.8, query: simpleEvent(1102, 1), summary: func(m map[string]any) string { return fmt.Sprintf("Audit-Log auf %s wurde gelöscht", str(m["host_name"])) }}, {name: "service_installed", severity: "high", eventCode: 7045, score: 8.0, query: simpleEvent(7045, 1), summary: func(m map[string]any) string { return fmt.Sprintf("Neuer Dienst auf %s installiert", str(m["host_name"])) }}, {name: "account_lockout", severity: "medium", eventCode: 4740, score: 5.5, query: func(s, e time.Time, t string) string { return fmt.Sprintf(`SELECT host_name, target_user AS user_name, '' AS source_ip, workstation, uniqExact(event_uid) cnt, min(event_time) window_start, max(event_time) window_end FROM siem.events WHERE tenant_id=%s AND event_time>=%s AND event_time<%s AND event_code=4740 GROUP BY host_name,user_name,workstation HAVING cnt>=1`, clickhouse.Q(t), clickhouse.Q(ts(s)), clickhouse.Q(ts(e))) }, summary: func(m map[string]any) string { return fmt.Sprintf("Account-Lockout: %s; Caller %s; DC/Host %s (%d×)", str(m["user_name"]), fallback(str(m["workstation"]), "unbekannt"), str(m["host_name"]), int64(num(m["cnt"]))) }}, {name: "failed_logon_burst", severity: "high", eventCode: 4625, score: 7.5, query: func(s, e time.Time, t string) string { return fmt.Sprintf(`SELECT host_name, target_user AS user_name, source_ip, workstation, uniqExact(event_uid) cnt, min(event_time) window_start, max(event_time) window_end FROM siem.events WHERE tenant_id=%s AND event_time>=%s AND event_time<%s AND event_code=4625 AND target_user!='' GROUP BY host_name,user_name,source_ip,workstation HAVING cnt>=20`, clickhouse.Q(t), clickhouse.Q(ts(s)), clickhouse.Q(ts(e))) }, summary: func(m map[string]any) string { return fmt.Sprintf("%d fehlgeschlagene Logons für %s auf %s", int64(num(m["cnt"])), str(m["user_name"]), str(m["host_name"])) }}, {name: "password_spray", severity: "high", eventCode: 4625, score: 8.5, query: func(s, e time.Time, t string) string { return fmt.Sprintf(`SELECT '' AS host_name, '' AS user_name, source_ip, '' AS workstation, uniqExact(target_user) users, uniqExact(event_uid) cnt, min(event_time) window_start, max(event_time) window_end FROM siem.events WHERE tenant_id=%s AND event_time>=%s AND event_time<%s AND event_code=4625 AND source_ip!='' AND target_user!='' GROUP BY source_ip HAVING users>=10 AND cnt>=20`, clickhouse.Q(t), clickhouse.Q(ts(s)), clickhouse.Q(ts(e))) }, summary: func(m map[string]any) string { return fmt.Sprintf("Password-Spray von %s gegen %.0f Benutzer (%d Versuche)", str(m["source_ip"]), num(m["users"]), int64(num(m["cnt"]))) }}, {name: "privileged_group_change", severity: "critical", eventCode: 4728, score: 9.2, query: func(s, e time.Time, t string) string { return fmt.Sprintf(`SELECT host_name, target_user AS user_name, '' AS source_ip, workstation, uniqExact(event_uid) cnt, min(event_time) window_start, max(event_time) window_end FROM siem.events WHERE tenant_id=%s AND event_time>=%s AND event_time<%s AND event_code IN (4728,4732,4756) GROUP BY host_name,user_name,workstation HAVING cnt>=1`, clickhouse.Q(t), clickhouse.Q(ts(s)), clickhouse.Q(ts(e))) }, summary: func(m map[string]any) string { return fmt.Sprintf("Privilegierte Gruppenmitgliedschaft geändert: %s auf %s", str(m["user_name"]), str(m["host_name"])) }}, } } func simpleEvent(id uint32, min int) func(time.Time, time.Time, string) string { return func(s, e time.Time, t string) string { return fmt.Sprintf(`SELECT host_name, '' AS user_name, '' AS source_ip, '' AS workstation, uniqExact(event_uid) cnt, min(event_time) window_start, max(event_time) window_end FROM siem.events WHERE tenant_id=%s AND event_time>=%s AND event_time<%s AND event_code=%d GROUP BY host_name HAVING cnt>=%d`, clickhouse.Q(t), clickhouse.Q(ts(s)), clickhouse.Q(ts(e)), id, min) } } func ts(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05.000") } func str(v any) string { if v == nil { return "" } return fmt.Sprint(v) } func num(v any) float64 { switch x := v.(type) { case float64: return x case jsonNumber: return x.Float() default: f, _ := strconv.ParseFloat(fmt.Sprint(v), 64) return f } } type jsonNumber string func (n jsonNumber) Float() float64 { f, _ := strconv.ParseFloat(string(n), 64); return f } func timeVal(v any, d time.Time) time.Time { s := str(v) for _, layout := range []string{"2006-01-02 15:04:05.999999", "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"} { if t, e := time.Parse(layout, s); e == nil { return t.UTC() } } return d } func fingerprint(v ...string) string { h := sha256.Sum256([]byte(strings.Join(v, "|"))) return hex.EncodeToString(h[:]) } func fallback(v, d string) string { if strings.TrimSpace(v) == "" { return d }; return v } func max64(a, b int64) int64 { if a > b { return a } return b }