153 lines
6.8 KiB
Go
153 lines
6.8 KiB
Go
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
|
||
}
|