Files
siem-backend/internal/detector/detector.go
groot c49bfa1c34
Some checks failed
release-tag / release-image (push) Failing after 5m54s
Vollständiges Redesign
2026-07-23 14:45:35 +02:00

153 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}