Funktionsrollback
Some checks failed
release-tag / release-image (push) Failing after 1m8s

This commit is contained in:
2026-07-24 07:17:38 +02:00
parent a4ff984914
commit e9d9583f28
38 changed files with 3243 additions and 364 deletions

View File

@@ -15,6 +15,7 @@ import (
"example.com/siem-greenfield/internal/clickhouse"
"example.com/siem-greenfield/internal/config"
"example.com/siem-greenfield/internal/postgres"
"example.com/siem-greenfield/internal/rules"
)
func Run(ctx context.Context, cfg config.Config) error {
@@ -31,20 +32,20 @@ func Run(ctx context.Context, cfg config.Config) error {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { j(w, 200, map[string]string{"status": "ok"}) })
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
cctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
c, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if e := pg.Pool.Ping(cctx); e != nil {
if e := pg.Pool.Ping(c); e != nil {
j(w, 503, map[string]string{"status": "not_ready", "component": "postgres"})
return
}
if e := ch.Exec(cctx, "SELECT 1"); e != nil {
if e := ch.Exec(c, "SELECT 1"); e != nil {
j(w, 503, map[string]string{"status": "not_ready", "component": "clickhouse"})
return
}
j(w, 200, map[string]string{"status": "ready"})
})
mux.HandleFunc("/ui", func(w http.ResponseWriter, r *http.Request) {
_ = tpl.Execute(w, map[string]string{"Tenant": cfg.TenantID})
_ = tpl.Execute(w, map[string]string{"Tenant": cfg.TenantID, "GrafanaPort": cfg.GrafanaPort, "GrafanaURL": cfg.GrafanaPublicURL})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
@@ -53,19 +54,44 @@ func Run(ctx context.Context, cfg config.Config) error {
}
http.NotFound(w, r)
})
mux.HandleFunc("/api/summary", func(w http.ResponseWriter, r *http.Request) { summary(w, r, cfg, pg, ch) })
mux.HandleFunc("/api/analytics", func(w http.ResponseWriter, r *http.Request) { analytics(w, r, cfg, ch) })
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { events(w, r, cfg, ch) })
mux.HandleFunc("/api/detections", func(w http.ResponseWriter, r *http.Request) { detections(w, r, cfg, pg) })
mux.HandleFunc("/api/detections/status", func(w http.ResponseWriter, r *http.Request) { detectionStatus(w, r, cfg, pg) })
mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) {
x, e := pg.ListAgents(r.Context(), cfg.TenantID)
if e != nil {
j(w, 500, map[string]string{"error": e.Error()})
j(w, 500, errj(e))
return
}
j(w, 200, x)
})
srv := &http.Server{Addr: cfg.ServiceAddr, Handler: security(basicAuth(mux, cfg.UIUsername, cfg.UIPassword)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
mux.HandleFunc("/api/agents/toggle", func(w http.ResponseWriter, r *http.Request) { agentToggle(w, r, cfg, pg) })
mux.HandleFunc("/api/rule-sets", func(w http.ResponseWriter, r *http.Request) {
x, e := pg.ListRuleSets(r.Context(), cfg.TenantID)
if e != nil {
j(w, 500, errj(e))
return
}
j(w, 200, x)
})
mux.HandleFunc("/api/rule-sets/toggle", func(w http.ResponseWriter, r *http.Request) { ruleSetToggle(w, r, cfg, pg) })
mux.HandleFunc("/api/rules", func(w http.ResponseWriter, r *http.Request) {
x, e := pg.ListRules(r.Context(), cfg.TenantID, false)
if e != nil {
j(w, 500, errj(e))
return
}
j(w, 200, x)
})
mux.HandleFunc("/api/rules/toggle", func(w http.ResponseWriter, r *http.Request) { ruleToggle(w, r, cfg, pg) })
mux.HandleFunc("/api/rules/save", func(w http.ResponseWriter, r *http.Request) { ruleSave(w, r, cfg, pg) })
mux.HandleFunc("/api/suppressions", func(w http.ResponseWriter, r *http.Request) { suppressions(w, r, cfg, pg) })
mux.HandleFunc("/api/suppressions/delete", func(w http.ResponseWriter, r *http.Request) { suppressionDelete(w, r, cfg, pg) })
srv := &http.Server{Addr: cfg.ServiceAddr, Handler: security(basicAuth(mux, cfg.UIUsername, cfg.UIPassword)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 35 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
<-ctx.Done()
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -79,13 +105,14 @@ func Run(ctx context.Context, cfg config.Config) error {
}
return e
}
func summary(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store, ch *clickhouse.Client) {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
q := fmt.Sprintf(`SELECT uniqExactMerge(cnt_state) events_24h, uniqExact(host_name) active_hosts FROM %s.events_5m WHERE tenant_id=%s AND bucket>=now()-INTERVAL 24 HOUR`, clickhouse.Ident(cfg.ClickHouseDB), clickhouse.Q(cfg.TenantID))
rows, e := ch.QueryJSON(ctx, q)
if e != nil {
j(w, 500, map[string]string{"error": e.Error()})
j(w, 500, errj(e))
return
}
out := map[string]any{"events_24h": 0, "active_hosts": 0}
@@ -96,65 +123,237 @@ func summary(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *post
}
dc, _ := pg.DetectionCounts(ctx, cfg.TenantID)
out["detections"] = dc
rs, _ := pg.ListRuleSets(ctx, cfg.TenantID)
var enabledRules int64
var setsOn int64
for _, x := range rs {
if x.Enabled {
setsOn++
enabledRules += x.EnabledRules
}
}
out["rule_sets_enabled"] = setsOn
out["rules_enabled"] = enabledRules
j(w, 200, out)
}
func analytics(w http.ResponseWriter, r *http.Request, cfg config.Config, ch *clickhouse.Client) {
hours := queryInt(r, "hours", 24, 1, 2160)
db := clickhouse.Ident(cfg.ClickHouseDB)
tenant := clickhouse.Q(cfg.TenantID)
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
series, e := ch.QueryJSON(ctx, fmt.Sprintf(`SELECT bucket,uniqExactMerge(cnt_state) events FROM %s.events_5m WHERE tenant_id=%s AND bucket>=now()-INTERVAL %d HOUR GROUP BY bucket ORDER BY bucket`, db, tenant, hours))
if e != nil {
j(w, 500, errj(e))
return
}
hosts, e := ch.QueryJSON(ctx, fmt.Sprintf(`SELECT host_name,uniqExactMerge(cnt_state) events FROM %s.events_5m WHERE tenant_id=%s AND bucket>=now()-INTERVAL %d HOUR GROUP BY host_name ORDER BY events DESC LIMIT 10`, db, tenant, hours))
if e != nil {
j(w, 500, errj(e))
return
}
codes, e := ch.QueryJSON(ctx, fmt.Sprintf(`SELECT event_code,uniqExactMerge(cnt_state) events FROM %s.events_5m WHERE tenant_id=%s AND bucket>=now()-INTERVAL %d HOUR GROUP BY event_code ORDER BY events DESC LIMIT 10`, db, tenant, hours))
if e != nil {
j(w, 500, errj(e))
return
}
auth, e := ch.QueryJSON(ctx, fmt.Sprintf(`SELECT event_code,uniqExactMerge(cnt_state) events FROM %s.events_5m WHERE tenant_id=%s AND bucket>=now()-INTERVAL %d HOUR AND event_code IN (4624,4625,4740,4771,4776) GROUP BY event_code ORDER BY event_code`, db, tenant, hours))
if e != nil {
j(w, 500, errj(e))
return
}
j(w, 200, map[string]any{"series": series, "top_hosts": hosts, "top_event_codes": codes, "authentication": auth})
}
func events(w http.ResponseWriter, r *http.Request, cfg config.Config, ch *clickhouse.Client) {
limit := cfg.UIQueryLimit
if n, e := strconv.Atoi(r.URL.Query().Get("limit")); e == nil && n > 0 && n <= 2000 {
limit = n
}
hours := 24
if n, e := strconv.Atoi(r.URL.Query().Get("hours")); e == nil && n > 0 && n <= 2160 {
hours = n
}
limit := queryInt(r, "limit", cfg.UIQueryLimit, 1, 2000)
hours := queryInt(r, "hours", 24, 1, 2160)
where := []string{"tenant_id=" + clickhouse.Q(cfg.TenantID), fmt.Sprintf("event_time>=now()-INTERVAL %d HOUR", hours)}
for key, col := range map[string]string{"host": "host_name", "user": "user_name", "ip": "source_ip", "channel": "channel", "action": "action"} {
exact := map[string]string{"host": "host_name", "ip": "source_ip", "channel": "channel", "action": "action", "outcome": "outcome"}
for key, col := range exact {
if v := strings.TrimSpace(r.URL.Query().Get(key)); v != "" {
where = append(where, col+"="+clickhouse.Q(v))
}
}
if v := strings.TrimSpace(r.URL.Query().Get("user")); v != "" {
q := clickhouse.Q(v)
where = append(where, "(user_name="+q+" OR target_user="+q+" OR subject_user="+q+")")
}
if v := strings.TrimSpace(r.URL.Query().Get("event_code")); v != "" {
if _, e := strconv.ParseUint(v, 10, 32); e == nil {
where = append(where, "event_code="+v)
}
}
q := fmt.Sprintf(`SELECT event_uid,event_time,host_name,channel,event_code,category,action,outcome,severity,user_name,subject_user,target_user,source_ip,workstation,process_path,message,raw_object_key,raw_index FROM %s.events WHERE %s ORDER BY event_time DESC, ingest_time DESC LIMIT 1 BY event_uid LIMIT %d`, clickhouse.Ident(cfg.ClickHouseDB), strings.Join(where, " AND "), limit)
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
if v := strings.TrimSpace(r.URL.Query().Get("query")); v != "" {
q := clickhouse.Q(v)
where = append(where, "(positionCaseInsensitiveUTF8(message,"+q+")>0 OR positionCaseInsensitiveUTF8(command_line,"+q+")>0 OR positionCaseInsensitiveUTF8(process_path,"+q+")>0)")
}
q := fmt.Sprintf(`SELECT event_uid,event_time,ingest_time,host_name,channel,provider,event_code,category,action,outcome,severity,user_name,subject_user,target_user,source_ip,destination_ip,workstation,logon_type,authentication_package,status_code,failure_reason,process_path,parent_process_path,command_line,message,attributes,raw_object_key,raw_index FROM %s.events WHERE %s ORDER BY event_time DESC,ingest_time DESC LIMIT 1 BY event_uid LIMIT %d`, clickhouse.Ident(cfg.ClickHouseDB), strings.Join(where, " AND "), limit)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
rows, e := ch.QueryJSON(ctx, q)
if e != nil {
j(w, 500, map[string]string{"error": e.Error()})
j(w, 500, errj(e))
return
}
j(w, 200, rows)
}
func detections(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
x, e := pg.ListDetections(r.Context(), cfg.TenantID, 500)
x, e := pg.ListDetections(r.Context(), cfg.TenantID, queryInt(r, "limit", 500, 1, 1000))
if e != nil {
j(w, 500, map[string]string{"error": e.Error()})
j(w, 500, errj(e))
return
}
j(w, 200, x)
}
func detectionStatus(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
if r.Method != http.MethodPost {
j(w, 405, map[string]string{"error": "method not allowed"})
if !post(w, r) {
return
}
var v struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
if e := json.NewDecoder(r.Body).Decode(&v); e != nil {
j(w, 400, map[string]string{"error": "invalid json"})
if decode(w, r, &v) != nil {
return
}
if e := pg.UpdateDetectionStatus(r.Context(), cfg.TenantID, v.ID, v.Status); e != nil {
j(w, 400, map[string]string{"error": e.Error()})
j(w, 400, errj(e))
return
}
j(w, 200, map[string]string{"status": "ok"})
j(w, 200, okj())
}
func agentToggle(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
if !post(w, r) {
return
}
var v struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
if decode(w, r, &v) != nil {
return
}
if e := pg.SetAgentEnabled(r.Context(), cfg.TenantID, v.ID, v.Enabled); e != nil {
j(w, 400, errj(e))
return
}
j(w, 200, okj())
}
func ruleToggle(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
if !post(w, r) {
return
}
var v struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
if decode(w, r, &v) != nil {
return
}
if e := pg.SetRuleEnabled(r.Context(), cfg.TenantID, v.ID, v.Enabled); e != nil {
j(w, 400, errj(e))
return
}
j(w, 200, okj())
}
func ruleSetToggle(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
if !post(w, r) {
return
}
var v struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
if decode(w, r, &v) != nil {
return
}
if e := pg.SetRuleSetEnabled(r.Context(), cfg.TenantID, v.ID, v.Enabled); e != nil {
j(w, 400, errj(e))
return
}
j(w, 200, okj())
}
func ruleSave(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
if !post(w, r) {
return
}
var v rules.Rule
if decode(w, r, &v) != nil {
return
}
if e := pg.SaveCustomRule(r.Context(), cfg.TenantID, v); e != nil {
j(w, 400, errj(e))
return
}
j(w, 200, okj())
}
func suppressions(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
if r.Method == http.MethodGet {
x, e := pg.ListSuppressions(r.Context(), cfg.TenantID)
if e != nil {
j(w, 500, errj(e))
return
}
j(w, 200, x)
return
}
if !post(w, r) {
return
}
var v postgres.Suppression
if decode(w, r, &v) != nil {
return
}
if e := pg.CreateSuppression(r.Context(), cfg.TenantID, v); e != nil {
j(w, 400, errj(e))
return
}
j(w, 200, okj())
}
func suppressionDelete(w http.ResponseWriter, r *http.Request, cfg config.Config, pg *postgres.Store) {
if !post(w, r) {
return
}
var v struct {
ID int64 `json:"id"`
}
if decode(w, r, &v) != nil {
return
}
if e := pg.DeleteSuppression(r.Context(), cfg.TenantID, v.ID); e != nil {
j(w, 400, errj(e))
return
}
j(w, 200, okj())
}
func queryInt(r *http.Request, key string, def, min, max int) int {
n, e := strconv.Atoi(r.URL.Query().Get(key))
if e != nil || n < min || n > max {
return def
}
return n
}
func decode(w http.ResponseWriter, r *http.Request, v any) error {
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
d.DisallowUnknownFields()
if e := d.Decode(v); e != nil {
j(w, 400, map[string]string{"error": "invalid json: " + e.Error()})
return e
}
return nil
}
func post(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodPost {
j(w, 405, map[string]string{"error": "method not allowed"})
return false
}
return true
}
func errj(e error) map[string]string { return map[string]string{"error": e.Error()} }
func okj() map[string]string { return map[string]string{"status": "ok"} }
func j(w http.ResponseWriter, s int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(s)
@@ -175,7 +374,6 @@ func basicAuth(next http.Handler, user, pass string) http.Handler {
next.ServeHTTP(w, r)
})
}
func security(n http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")

View File

@@ -29,6 +29,9 @@ type Config struct {
UIQueryLimit int
UIUsername string
UIPassword string
RulesDir string
GrafanaPort string
GrafanaPublicURL string
}
func Load() Config {
@@ -54,6 +57,9 @@ func Load() Config {
UIQueryLimit: envInt("UI_QUERY_LIMIT", 500),
UIUsername: env("UI_USERNAME", "admin"),
UIPassword: env("UI_PASSWORD", "change-me"),
RulesDir: env("RULES_DIR", "/app/rules"),
GrafanaPort: env("GRAFANA_PORT", "3000"),
GrafanaPublicURL: env("GRAFANA_PUBLIC_URL", ""),
}
}

View File

@@ -47,48 +47,48 @@ type IngestEnvelope struct {
}
type CanonicalEvent struct {
EventUID string `json:"event_uid"`
QueuePartition int32 `json:"queue_partition"`
QueueOffset int64 `json:"queue_offset"`
TenantID string `json:"tenant_id"`
EventTime string `json:"event_time"`
IngestTime string `json:"ingest_time"`
AgentID string `json:"agent_id"`
HostName string `json:"host_name"`
SourceType string `json:"source_type"`
Channel string `json:"channel"`
Provider string `json:"provider"`
EventCode uint32 `json:"event_code"`
Category string `json:"category"`
Action string `json:"action"`
Outcome string `json:"outcome"`
Severity uint8 `json:"severity"`
UserName string `json:"user_name"`
UserDomain string `json:"user_domain"`
SubjectUser string `json:"subject_user"`
SubjectDomain string `json:"subject_domain"`
TargetUser string `json:"target_user"`
TargetDomain string `json:"target_domain"`
SourceIP string `json:"source_ip"`
SourcePort uint16 `json:"source_port"`
DestinationIP string `json:"destination_ip"`
DestinationPort uint16 `json:"destination_port"`
Workstation string `json:"workstation"`
LogonType string `json:"logon_type"`
AuthenticationPackage string `json:"authentication_package"`
LogonProcess string `json:"logon_process"`
StatusCode string `json:"status_code"`
SubStatusCode string `json:"sub_status_code"`
FailureReason string `json:"failure_reason"`
ProcessPath string `json:"process_path"`
ParentProcessPath string `json:"parent_process_path"`
CommandLine string `json:"command_line"`
Message string `json:"message"`
Attributes map[string]string `json:"attributes"`
RawObjectKey string `json:"raw_object_key"`
RawIndex uint32 `json:"raw_index"`
PayloadHash string `json:"payload_hash"`
SchemaVersion uint16 `json:"schema_version"`
ParserVersion uint16 `json:"parser_version"`
IngestDelayMS int64 `json:"ingest_delay_ms"`
EventUID string `json:"event_uid"`
QueuePartition int32 `json:"queue_partition"`
QueueOffset int64 `json:"queue_offset"`
TenantID string `json:"tenant_id"`
EventTime string `json:"event_time"`
IngestTime string `json:"ingest_time"`
AgentID string `json:"agent_id"`
HostName string `json:"host_name"`
SourceType string `json:"source_type"`
Channel string `json:"channel"`
Provider string `json:"provider"`
EventCode uint32 `json:"event_code"`
Category string `json:"category"`
Action string `json:"action"`
Outcome string `json:"outcome"`
Severity uint8 `json:"severity"`
UserName string `json:"user_name"`
UserDomain string `json:"user_domain"`
SubjectUser string `json:"subject_user"`
SubjectDomain string `json:"subject_domain"`
TargetUser string `json:"target_user"`
TargetDomain string `json:"target_domain"`
SourceIP string `json:"source_ip"`
SourcePort uint16 `json:"source_port"`
DestinationIP string `json:"destination_ip"`
DestinationPort uint16 `json:"destination_port"`
Workstation string `json:"workstation"`
LogonType string `json:"logon_type"`
AuthenticationPackage string `json:"authentication_package"`
LogonProcess string `json:"logon_process"`
StatusCode string `json:"status_code"`
SubStatusCode string `json:"sub_status_code"`
FailureReason string `json:"failure_reason"`
ProcessPath string `json:"process_path"`
ParentProcessPath string `json:"parent_process_path"`
CommandLine string `json:"command_line"`
Message string `json:"message"`
Attributes map[string]string `json:"attributes"`
RawObjectKey string `json:"raw_object_key"`
RawIndex uint32 `json:"raw_index"`
PayloadHash string `json:"payload_hash"`
SchemaVersion uint16 `json:"schema_version"`
ParserVersion uint16 `json:"parser_version"`
IngestDelayMS int64 `json:"ingest_delay_ms"`
}

View File

@@ -13,22 +13,21 @@ import (
"example.com/siem-greenfield/internal/clickhouse"
"example.com/siem-greenfield/internal/config"
"example.com/siem-greenfield/internal/postgres"
"example.com/siem-greenfield/internal/rules"
)
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()
if e := syncBuiltins(ctx, cfg, pg); e != nil {
return fmt.Errorf("sync built-in rules: %w", e)
}
if e := pg.EnsureCustomRuleSet(ctx, cfg.TenantID); e != nil {
return e
}
ch := clickhouse.New(cfg)
ticker := time.NewTicker(cfg.DetectorInterval)
defer ticker.Stop()
@@ -47,69 +46,79 @@ func Run(ctx context.Context, cfg config.Config) error {
}
}
}
func syncBuiltins(ctx context.Context, cfg config.Config, pg *postgres.Store) error {
sets, e := rules.LoadDir(cfg.RulesDir)
if e != nil {
return e
}
keep := make([]string, 0, len(sets))
for _, rs := range sets {
keep = append(keep, rs.ID)
if e := pg.SyncRuleSet(ctx, cfg.TenantID, rs); e != nil {
return fmt.Errorf("%s: %w", rs.ID, e)
}
}
if e := pg.PruneBuiltinRuleSets(ctx, cfg.TenantID, keep); e != nil {
return fmt.Errorf("prune built-in rule sets: %w", e)
}
log.Printf("rule engine: synchronized %d built-in rule sets", len(sets))
return nil
}
func runAll(ctx context.Context, cfg config.Config, pg *postgres.Store, ch *clickhouse.Client) error {
enabled, e := pg.ListRules(ctx, cfg.TenantID, true)
if e != nil {
return e
}
end := time.Now().UTC()
start := end.Add(-cfg.DetectorLookback)
for _, r := range rules(cfg.ClickHouseDB) {
q := r.query(start, end, cfg.TenantID)
rows, e := ch.QueryJSON(ctx, q)
for _, sr := range enabled {
q, _, e := rules.Compile(sr.Rule, cfg.ClickHouseDB, cfg.TenantID, end)
if e != nil {
return fmt.Errorf("%s: %w", r.name, e)
log.Printf("rule %s compile: %v", sr.ID, e)
continue
}
qctx, cancel := context.WithTimeout(ctx, 20*time.Second)
rows, e := ch.QueryJSON(qctx, q)
cancel()
if e != nil {
log.Printf("rule %s query: %v", sr.ID, e)
continue
}
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)
ws := timeVal(row["window_start"], end.Add(-time.Duration(sr.WindowSeconds)*time.Second))
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)}
suppressed, e := pg.IsSuppressed(ctx, cfg.TenantID, sr.ID, host, user, ip, we)
if e != nil {
log.Printf("rule %s suppression: %v", sr.ID, e)
continue
}
if suppressed {
continue
}
bucket := sr.SuppressSeconds
if bucket <= 0 {
bucket = sr.WindowSeconds
}
if bucket < 60 {
bucket = 60
}
fp := fingerprint(sr.ID, host, user, ip, workstation, strconv.FormatInt(ws.Unix()/int64(bucket), 10))
count := int64(num(row["cnt"]))
eventCode := uint32(num(row["event_code"]))
d := postgres.Detection{Fingerprint: fp, RuleID: sr.ID, RuleSetID: sr.RuleSetID, RuleName: sr.Title, Severity: sr.Severity, Hostname: host, UserName: user, SourceIP: ip, Workstation: workstation, EventCode: eventCode, Score: sr.Score, WindowStart: ws, WindowEnd: we, Summary: rules.RenderSummary(sr.Summary, row), Count: max64(1, count), Tags: sr.Tags, MITRE: sr.MITRE}
if e := pg.UpsertDetection(ctx, d, cfg.TenantID); e != nil {
return e
log.Printf("rule %s detection: %v", sr.ID, e)
}
}
}
return nil
}
func rules(db string) []rule {
table := clickhouse.Ident(db) + ".events"
return []rule{
{name: "audit_log_cleared", severity: "critical", eventCode: 1102, score: 9.8, query: simpleEvent(table, 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(table, 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 %s 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`, table, 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 %s 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`, table, 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 %s 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`, table, 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 %s 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`, table, 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(table string, 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 %s WHERE tenant_id=%s AND event_time>=%s AND event_time<%s AND event_code=%d GROUP BY host_name HAVING cnt>=%d`, table, 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 ""
@@ -144,12 +153,6 @@ 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

View File

@@ -22,8 +22,12 @@ func TestValidateRequiresMessageOrMetadata(t *testing.T) {
}
func TestBatchUIDIsDeterministic(t *testing.T) {
b := []contracts.LogPayload{{Hostname: "PC01", Channel: "Security", EventID: 4625, Source: "agent", Time: time.Date(2026,7,23,12,0,0,0,time.UTC), Metadata: &contracts.EventMetadataPayload{TargetUser: "alice"}}}
b := []contracts.LogPayload{{Hostname: "PC01", Channel: "Security", EventID: 4625, Source: "agent", Time: time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC), Metadata: &contracts.EventMetadataPayload{TargetUser: "alice"}}}
a := batchUID("agent-1", b)
if a == "" || a != batchUID("agent-1", b) { t.Fatalf("batch uid is not deterministic") }
if a == batchUID("agent-2", b) { t.Fatalf("batch uid must be scoped to agent") }
if a == "" || a != batchUID("agent-1", b) {
t.Fatalf("batch uid is not deterministic")
}
if a == batchUID("agent-2", b) {
t.Fatalf("batch uid must be scoped to agent")
}
}

View File

@@ -5,12 +5,14 @@ import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
"time"
"example.com/siem-greenfield/internal/rules"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,9 +29,12 @@ type Agent struct {
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
type Detection struct {
ID int64 `json:"id"`
Fingerprint string `json:"fingerprint"`
RuleID string `json:"rule_id"`
RuleSetID string `json:"rule_set_id"`
RuleName string `json:"rule_name"`
Severity string `json:"severity"`
Status string `json:"status"`
@@ -45,6 +50,33 @@ type Detection struct {
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
Count int64 `json:"count"`
Tags []string `json:"tags"`
MITRE []string `json:"mitre"`
}
type RuleSetRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version int `json:"version"`
Enabled bool `json:"enabled"`
Source string `json:"source"`
Locked bool `json:"locked"`
RuleCount int64 `json:"rule_count"`
EnabledRules int64 `json:"enabled_rules"`
UpdatedAt time.Time `json:"updated_at"`
}
type Suppression struct {
ID int64 `json:"id"`
RuleID string `json:"rule_id"`
HostPattern string `json:"host_pattern"`
UserPattern string `json:"user_pattern"`
SourceIPPattern string `json:"source_ip_pattern"`
Reason string `json:"reason"`
Enabled bool `json:"enabled"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func Open(ctx context.Context, url string) (*Store, error) {
@@ -82,7 +114,6 @@ func (s *Store) AuthenticateOrEnroll(ctx context.Context, tenant, hostname, apiK
if !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
// A concurrent first request may have enrolled the host between SELECT and INSERT.
err = s.Pool.QueryRow(ctx, `SELECT id::text, api_key_hash, enabled FROM agents WHERE tenant_id=$1 AND hostname=$2`, tenant, hostname).Scan(&id, &hash, &enabled)
if err != nil || !enabled || !secureEqual(strings.ToLower(hash), hashHex(apiKey)) {
return "", ErrUnauthorized
@@ -112,23 +143,209 @@ func (s *Store) ListAgents(ctx context.Context, tenant string) ([]Agent, error)
}
return out, rows.Err()
}
func (s *Store) SetAgentEnabled(ctx context.Context, tenant, id string, enabled bool) error {
tag, e := s.Pool.Exec(ctx, `UPDATE agents SET enabled=$3 WHERE tenant_id=$1 AND id=$2::uuid`, tenant, id, enabled)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) SyncRuleSet(ctx context.Context, tenant string, rs rules.RuleSet) error {
tx, e := s.Pool.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
_, e = tx.Exec(ctx, `INSERT INTO rule_sets(tenant_id,id,name,description,version,enabled,source,locked)
VALUES($1,$2,$3,$4,$5,$6,'builtin',true)
ON CONFLICT(tenant_id,id) DO UPDATE SET name=EXCLUDED.name,description=EXCLUDED.description,version=GREATEST(rule_sets.version,EXCLUDED.version),source='builtin',locked=true,updated_at=now()`, tenant, rs.ID, rs.Name, rs.Description, rs.Version, rs.Enabled)
if e != nil {
return e
}
ids := make([]string, 0, len(rs.Rules))
for _, r := range rs.Rules {
ids = append(ids, r.ID)
b, e := json.Marshal(r)
if e != nil {
return e
}
_, e = tx.Exec(ctx, `INSERT INTO detection_rules(tenant_id,id,rule_set_id,title,severity,score,enabled,source,definition)
VALUES($1,$2,$3,$4,$5,$6,$7,'builtin',$8::jsonb)
ON CONFLICT(tenant_id,id) DO UPDATE SET rule_set_id=EXCLUDED.rule_set_id,title=EXCLUDED.title,severity=EXCLUDED.severity,score=EXCLUDED.score,source='builtin',definition=EXCLUDED.definition,updated_at=now()`, tenant, r.ID, rs.ID, r.Title, r.Severity, r.Score, r.Enabled, string(b))
if e != nil {
return e
}
}
if len(ids) == 0 {
_, e = tx.Exec(ctx, `DELETE FROM detection_rules WHERE tenant_id=$1 AND rule_set_id=$2 AND source='builtin'`, tenant, rs.ID)
} else {
_, e = tx.Exec(ctx, `DELETE FROM detection_rules WHERE tenant_id=$1 AND rule_set_id=$2 AND source='builtin' AND NOT (id = ANY($3))`, tenant, rs.ID, ids)
}
if e != nil {
return e
}
return tx.Commit(ctx)
}
func (s *Store) PruneBuiltinRuleSets(ctx context.Context, tenant string, keep []string) error {
if len(keep) == 0 {
_, e := s.Pool.Exec(ctx, `DELETE FROM rule_sets WHERE tenant_id=$1 AND source='builtin'`, tenant)
return e
}
_, e := s.Pool.Exec(ctx, `DELETE FROM rule_sets WHERE tenant_id=$1 AND source='builtin' AND NOT (id = ANY($2))`, tenant, keep)
return e
}
func (s *Store) EnsureCustomRuleSet(ctx context.Context, tenant string) error {
_, e := s.Pool.Exec(ctx, `INSERT INTO rule_sets(tenant_id,id,name,description,version,enabled,source,locked)
VALUES($1,'custom','Eigene Regeln','Über die SIEM-Oberfläche verwaltete Regeln',1,true,'custom',false)
ON CONFLICT(tenant_id,id) DO NOTHING`, tenant)
return e
}
func (s *Store) ListRuleSets(ctx context.Context, tenant string) ([]RuleSetRecord, error) {
rows, e := s.Pool.Query(ctx, `SELECT rs.id,rs.name,rs.description,rs.version,rs.enabled,rs.source,rs.locked,rs.updated_at,
count(r.id),count(r.id) FILTER (WHERE r.enabled)
FROM rule_sets rs LEFT JOIN detection_rules r ON r.tenant_id=rs.tenant_id AND r.rule_set_id=rs.id
WHERE rs.tenant_id=$1 GROUP BY rs.id,rs.name,rs.description,rs.version,rs.enabled,rs.source,rs.locked,rs.updated_at ORDER BY rs.name`, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
var out []RuleSetRecord
for rows.Next() {
var x RuleSetRecord
if e := rows.Scan(&x.ID, &x.Name, &x.Description, &x.Version, &x.Enabled, &x.Source, &x.Locked, &x.UpdatedAt, &x.RuleCount, &x.EnabledRules); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) ListRules(ctx context.Context, tenant string, enabledOnly bool) ([]rules.StoredRule, error) {
q := `SELECT r.rule_set_id,rs.name,rs.enabled,r.source,r.enabled,r.definition FROM detection_rules r JOIN rule_sets rs ON rs.tenant_id=r.tenant_id AND rs.id=r.rule_set_id WHERE r.tenant_id=$1`
if enabledOnly {
q += ` AND r.enabled AND rs.enabled`
}
q += ` ORDER BY rs.name,r.severity DESC,r.title`
rows, e := s.Pool.Query(ctx, q, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
var out []rules.StoredRule
for rows.Next() {
var x rules.StoredRule
var b []byte
var enabled bool
if e := rows.Scan(&x.RuleSetID, &x.RuleSetName, &x.RuleSetOn, &x.Source, &enabled, &b); e != nil {
return nil, e
}
if e := json.Unmarshal(b, &x.Rule); e != nil {
return nil, e
}
x.Enabled = enabled
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) SetRuleEnabled(ctx context.Context, tenant, id string, enabled bool) error {
tag, e := s.Pool.Exec(ctx, `UPDATE detection_rules SET enabled=$3,updated_at=now() WHERE tenant_id=$1 AND id=$2`, tenant, id, enabled)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) SetRuleSetEnabled(ctx context.Context, tenant, id string, enabled bool) error {
tag, e := s.Pool.Exec(ctx, `UPDATE rule_sets SET enabled=$3,updated_at=now() WHERE tenant_id=$1 AND id=$2`, tenant, id, enabled)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *Store) SaveCustomRule(ctx context.Context, tenant string, r rules.Rule) error {
if err := rules.Validate(r); err != nil {
return err
}
if err := s.EnsureCustomRuleSet(ctx, tenant); err != nil {
return err
}
b, e := json.Marshal(r)
if e != nil {
return e
}
tag, e := s.Pool.Exec(ctx, `INSERT INTO detection_rules(tenant_id,id,rule_set_id,title,severity,score,enabled,source,definition)
VALUES($1,$2,'custom',$3,$4,$5,$6,'custom',$7::jsonb)
ON CONFLICT(tenant_id,id) DO UPDATE SET rule_set_id='custom',title=EXCLUDED.title,severity=EXCLUDED.severity,score=EXCLUDED.score,enabled=EXCLUDED.enabled,definition=EXCLUDED.definition,updated_at=now()
WHERE detection_rules.source='custom'`, tenant, r.ID, r.Title, r.Severity, r.Score, r.Enabled, string(b))
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("rule id %q belongs to a built-in rule set", r.ID)
}
return nil
}
func (s *Store) CreateSuppression(ctx context.Context, tenant string, x Suppression) error {
if x.RuleID == "" && x.HostPattern == "" && x.UserPattern == "" && x.SourceIPPattern == "" {
return fmt.Errorf("suppression must constrain rule or entity")
}
_, e := s.Pool.Exec(ctx, `INSERT INTO detection_suppressions(tenant_id,rule_id,host_pattern,user_pattern,source_ip_pattern,reason,enabled,expires_at) VALUES($1,$2,$3,$4,$5,$6,true,$7)`, tenant, x.RuleID, x.HostPattern, x.UserPattern, x.SourceIPPattern, x.Reason, x.ExpiresAt)
return e
}
func (s *Store) DeleteSuppression(ctx context.Context, tenant string, id int64) error {
_, e := s.Pool.Exec(ctx, `DELETE FROM detection_suppressions WHERE tenant_id=$1 AND id=$2`, tenant, id)
return e
}
func (s *Store) ListSuppressions(ctx context.Context, tenant string) ([]Suppression, error) {
rows, e := s.Pool.Query(ctx, `SELECT id,rule_id,host_pattern,user_pattern,source_ip_pattern,reason,enabled,expires_at,created_at FROM detection_suppressions WHERE tenant_id=$1 ORDER BY created_at DESC`, tenant)
if e != nil {
return nil, e
}
defer rows.Close()
var out []Suppression
for rows.Next() {
var x Suppression
if e := rows.Scan(&x.ID, &x.RuleID, &x.HostPattern, &x.UserPattern, &x.SourceIPPattern, &x.Reason, &x.Enabled, &x.ExpiresAt, &x.CreatedAt); e != nil {
return nil, e
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *Store) IsSuppressed(ctx context.Context, tenant, ruleID, host, user, ip string, at time.Time) (bool, error) {
var ok bool
e := s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM detection_suppressions WHERE tenant_id=$1 AND enabled AND (expires_at IS NULL OR expires_at>$6)
AND (rule_id='' OR rule_id=$2)
AND (host_pattern='' OR $3 LIKE replace(host_pattern,'*','%'))
AND (user_pattern='' OR $4 LIKE replace(user_pattern,'*','%'))
AND (source_ip_pattern='' OR $5 LIKE replace(source_ip_pattern,'*','%')))`, tenant, ruleID, host, user, ip, at).Scan(&ok)
return ok, e
}
func (s *Store) UpsertDetection(ctx context.Context, d Detection, tenant string) error {
_, e := s.Pool.Exec(ctx, `INSERT INTO detections(tenant_id,fingerprint,rule_name,severity,status,hostname,user_name,source_ip,workstation,event_code,score,window_start,window_end,summary,hit_count,first_seen,last_seen)
VALUES($1,$2,$3,$4,'open',$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$11,$12)
ON CONFLICT(tenant_id,fingerprint) DO UPDATE SET
first_seen=LEAST(detections.first_seen,EXCLUDED.first_seen),
last_seen=GREATEST(detections.last_seen,EXCLUDED.last_seen),
window_start=LEAST(detections.window_start,EXCLUDED.window_start),
window_end=GREATEST(detections.window_end,EXCLUDED.window_end),
hit_count=GREATEST(detections.hit_count,EXCLUDED.hit_count),
score=GREATEST(detections.score,EXCLUDED.score),
summary=EXCLUDED.summary, workstation=EXCLUDED.workstation, updated_at=now()`, tenant, d.Fingerprint, d.RuleName, d.Severity, d.Hostname, d.UserName, d.SourceIP, d.Workstation, d.EventCode, d.Score, d.WindowStart, d.WindowEnd, d.Summary, d.Count)
tags, _ := json.Marshal(d.Tags)
mitre, _ := json.Marshal(d.MITRE)
_, e := s.Pool.Exec(ctx, `INSERT INTO detections(tenant_id,fingerprint,rule_id,rule_set_id,rule_name,severity,status,hostname,user_name,source_ip,workstation,event_code,score,window_start,window_end,summary,hit_count,tags,mitre,first_seen,last_seen)
VALUES($1,$2,$3,$4,$5,$6,'open',$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17::jsonb,$18::jsonb,$13,$14)
ON CONFLICT(tenant_id,fingerprint) DO UPDATE SET first_seen=LEAST(detections.first_seen,EXCLUDED.first_seen),last_seen=GREATEST(detections.last_seen,EXCLUDED.last_seen),window_start=LEAST(detections.window_start,EXCLUDED.window_start),window_end=GREATEST(detections.window_end,EXCLUDED.window_end),hit_count=GREATEST(detections.hit_count,EXCLUDED.hit_count),score=GREATEST(detections.score,EXCLUDED.score),summary=EXCLUDED.summary,workstation=EXCLUDED.workstation,tags=EXCLUDED.tags,mitre=EXCLUDED.mitre,updated_at=now()`, tenant, d.Fingerprint, d.RuleID, d.RuleSetID, d.RuleName, d.Severity, d.Hostname, d.UserName, d.SourceIP, d.Workstation, d.EventCode, d.Score, d.WindowStart, d.WindowEnd, d.Summary, d.Count, string(tags), string(mitre))
return e
}
func (s *Store) ListDetections(ctx context.Context, tenant string, limit int) ([]Detection, error) {
rows, e := s.Pool.Query(ctx, `SELECT id,fingerprint,rule_name,severity,status,hostname,user_name,source_ip,workstation,event_code,score,window_start,window_end,summary,hit_count,first_seen,last_seen FROM detections WHERE tenant_id=$1 ORDER BY last_seen DESC LIMIT $2`, tenant, limit)
rows, e := s.Pool.Query(ctx, `SELECT id,fingerprint,rule_id,rule_set_id,rule_name,severity,status,hostname,user_name,source_ip,workstation,event_code,score,window_start,window_end,summary,hit_count,tags,mitre,first_seen,last_seen FROM detections WHERE tenant_id=$1 ORDER BY last_seen DESC LIMIT $2`, tenant, limit)
if e != nil {
return nil, e
}
@@ -136,9 +353,12 @@ func (s *Store) ListDetections(ctx context.Context, tenant string, limit int) ([
var out []Detection
for rows.Next() {
var d Detection
if e := rows.Scan(&d.ID, &d.Fingerprint, &d.RuleName, &d.Severity, &d.Status, &d.Hostname, &d.UserName, &d.SourceIP, &d.Workstation, &d.EventCode, &d.Score, &d.WindowStart, &d.WindowEnd, &d.Summary, &d.Count, &d.FirstSeen, &d.LastSeen); e != nil {
var tags, mitre []byte
if e := rows.Scan(&d.ID, &d.Fingerprint, &d.RuleID, &d.RuleSetID, &d.RuleName, &d.Severity, &d.Status, &d.Hostname, &d.UserName, &d.SourceIP, &d.Workstation, &d.EventCode, &d.Score, &d.WindowStart, &d.WindowEnd, &d.Summary, &d.Count, &tags, &mitre, &d.FirstSeen, &d.LastSeen); e != nil {
return nil, e
}
_ = json.Unmarshal(tags, &d.Tags)
_ = json.Unmarshal(mitre, &d.MITRE)
out = append(out, d)
}
return out, rows.Err()
@@ -147,7 +367,7 @@ func (s *Store) UpdateDetectionStatus(ctx context.Context, tenant string, id int
if status != "open" && status != "investigating" && status != "closed" && status != "false_positive" {
return fmt.Errorf("invalid status")
}
_, e := s.Pool.Exec(ctx, `UPDATE detections SET status=$3, updated_at=now() WHERE tenant_id=$1 AND id=$2`, tenant, id, status)
_, e := s.Pool.Exec(ctx, `UPDATE detections SET status=$3,updated_at=now() WHERE tenant_id=$1 AND id=$2`, tenant, id, status)
return e
}
func (s *Store) DetectionCounts(ctx context.Context, tenant string) (map[string]int64, error) {

355
internal/rules/rules.go Normal file
View File

@@ -0,0 +1,355 @@
package rules
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"example.com/siem-greenfield/internal/clickhouse"
)
type RuleSet struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version int `json:"version"`
Enabled bool `json:"enabled"`
Rules []Rule `json:"rules"`
}
type Rule struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Score float64 `json:"score"`
Enabled bool `json:"enabled"`
Kind string `json:"kind"`
Channels []string `json:"channels,omitempty"`
EventCodes []uint32 `json:"event_codes,omitempty"`
Conditions []Condition `json:"conditions,omitempty"`
GroupBy []string `json:"group_by,omitempty"`
Threshold int64 `json:"threshold,omitempty"`
DistinctField string `json:"distinct_field,omitempty"`
DistinctThreshold int64 `json:"distinct_threshold,omitempty"`
WindowSeconds int `json:"window_seconds"`
SuppressSeconds int `json:"suppress_seconds,omitempty"`
Summary string `json:"summary"`
Tags []string `json:"tags,omitempty"`
MITRE []string `json:"mitre,omitempty"`
}
type Condition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value,omitempty"`
}
type StoredRule struct {
RuleSetID string `json:"rule_set_id"`
RuleSetName string `json:"rule_set_name"`
RuleSetOn bool `json:"rule_set_enabled"`
Source string `json:"source"`
Rule
}
func LoadDir(dir string) ([]RuleSet, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var out []RuleSet
for _, ent := range entries {
if ent.IsDir() || !strings.HasSuffix(strings.ToLower(ent.Name()), ".json") {
continue
}
b, err := os.ReadFile(filepath.Join(dir, ent.Name()))
if err != nil {
return nil, err
}
var rs RuleSet
if err := json.Unmarshal(b, &rs); err != nil {
return nil, fmt.Errorf("%s: %w", ent.Name(), err)
}
if err := ValidateSet(rs); err != nil {
return nil, fmt.Errorf("%s: %w", ent.Name(), err)
}
out = append(out, rs)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func ValidateSet(rs RuleSet) error {
if !validID(rs.ID) || strings.TrimSpace(rs.Name) == "" {
return fmt.Errorf("invalid rule-set id/name")
}
if rs.Version <= 0 {
return fmt.Errorf("rule-set %s: version must be positive", rs.ID)
}
seen := map[string]bool{}
for i := range rs.Rules {
if err := Validate(rs.Rules[i]); err != nil {
return fmt.Errorf("rule %d: %w", i+1, err)
}
if seen[rs.Rules[i].ID] {
return fmt.Errorf("duplicate rule id %q", rs.Rules[i].ID)
}
seen[rs.Rules[i].ID] = true
}
return nil
}
func Validate(r Rule) error {
if !validID(r.ID) || strings.TrimSpace(r.Title) == "" {
return fmt.Errorf("invalid id/title")
}
switch r.Severity {
case "info", "low", "medium", "high", "critical":
default:
return fmt.Errorf("invalid severity %q", r.Severity)
}
if r.Score < 0 || r.Score > 100 {
return fmt.Errorf("score must be between 0 and 100")
}
switch r.Kind {
case "event", "threshold", "distinct":
default:
return fmt.Errorf("invalid kind %q", r.Kind)
}
if len(r.EventCodes) == 0 && len(r.Conditions) == 0 {
return fmt.Errorf("rule must constrain event_codes or conditions")
}
if r.WindowSeconds < 30 || r.WindowSeconds > 86400 {
return fmt.Errorf("window_seconds must be between 30 and 86400")
}
if r.Threshold <= 0 {
r.Threshold = 1
}
if r.Kind == "distinct" {
if _, ok := fieldExpr(r.DistinctField); !ok {
return fmt.Errorf("invalid distinct_field %q", r.DistinctField)
}
if r.DistinctThreshold <= 0 {
return fmt.Errorf("distinct_threshold must be positive")
}
}
for _, g := range r.GroupBy {
if _, ok := groupExpr(g); !ok {
return fmt.Errorf("invalid group_by field %q", g)
}
}
for _, c := range r.Conditions {
if _, ok := fieldExpr(c.Field); !ok {
return fmt.Errorf("invalid condition field %q", c.Field)
}
switch c.Operator {
case "equals", "not_equals", "contains", "not_contains", "regex", "exists", "not_exists", "in":
default:
return fmt.Errorf("invalid operator %q", c.Operator)
}
}
return nil
}
func Compile(r Rule, db, tenant string, end time.Time) (string, time.Time, error) {
if err := Validate(r); err != nil {
return "", time.Time{}, err
}
start := end.Add(-time.Duration(r.WindowSeconds) * time.Second)
table := clickhouse.Ident(db) + ".events"
where := []string{
"tenant_id=" + clickhouse.Q(tenant),
"event_time>=" + clickhouse.Q(ts(start)),
"event_time<" + clickhouse.Q(ts(end)),
}
if len(r.Channels) > 0 {
where = append(where, "channel IN ("+quoteStrings(r.Channels)+")")
}
if len(r.EventCodes) > 0 {
vals := make([]string, 0, len(r.EventCodes))
for _, id := range r.EventCodes {
vals = append(vals, strconv.FormatUint(uint64(id), 10))
}
where = append(where, "event_code IN ("+strings.Join(vals, ",")+")")
}
for _, c := range r.Conditions {
x, err := compileCondition(c)
if err != nil {
return "", time.Time{}, err
}
where = append(where, x)
}
groupFields := make([]string, 0, len(r.GroupBy))
selectFields := make([]string, 0, len(r.GroupBy)+8)
selected := map[string]bool{}
for _, g := range r.GroupBy {
expr, _ := groupExpr(g)
alias := groupAlias(g)
selectFields = append(selectFields, expr+" AS "+alias)
groupFields = append(groupFields, expr)
selected[g] = true
}
addContext := func(key, alias string) {
if !selected[key] {
selectFields = append(selectFields, "'' AS "+alias)
}
}
addContext("host", "host_name")
addContext("user", "user_name")
addContext("source_ip", "source_ip")
addContext("workstation", "workstation")
addContext("process_path", "process_path")
if selected["event_code"] {
// already selected under event_code
} else if len(r.EventCodes) == 1 {
selectFields = append(selectFields, strconv.FormatUint(uint64(r.EventCodes[0]), 10)+" AS event_code")
} else {
selectFields = append(selectFields, "toUInt32(0) AS event_code")
}
selectFields = append(selectFields,
"uniqExact(event_uid) AS cnt",
"min(event_time) AS window_start",
"max(event_time) AS window_end",
)
if r.Kind == "distinct" {
expr, _ := fieldExpr(r.DistinctField)
selectFields = append(selectFields, "uniqExact("+expr+") AS distinct_cnt")
}
having := fmt.Sprintf("cnt >= %d", max64(1, r.Threshold))
if r.Kind == "distinct" {
having += fmt.Sprintf(" AND distinct_cnt >= %d", r.DistinctThreshold)
}
q := "SELECT " + strings.Join(selectFields, ", ") + " FROM " + table + " WHERE " + strings.Join(where, " AND ")
if len(groupFields) > 0 {
q += " GROUP BY " + strings.Join(groupFields, ", ")
}
q += " HAVING " + having
return q, start, nil
}
func RenderSummary(tpl string, row map[string]any) string {
vals := map[string]string{
"host": value(row["host_name"]),
"user": value(row["user_name"]),
"source_ip": value(row["source_ip"]),
"workstation": value(row["workstation"]),
"process": value(row["process_path"]),
"event_code": value(row["event_code"]),
"count": value(row["cnt"]),
"distinct": value(row["distinct_cnt"]),
}
out := tpl
for k, v := range vals {
out = strings.ReplaceAll(out, "{"+k+"}", v)
}
if strings.TrimSpace(out) == "" {
out = "Rule matched on " + vals["host"]
}
return out
}
func fieldExpr(name string) (string, bool) {
fields := map[string]string{
"host": "host_name", "host_name": "host_name",
"user": "multiIf(target_user!='',target_user,user_name!='',user_name,subject_user)",
"user_name": "user_name", "target_user": "target_user", "subject_user": "subject_user",
"source_ip": "source_ip", "destination_ip": "destination_ip", "workstation": "workstation",
"process_path": "process_path", "parent_process_path": "parent_process_path", "command_line": "command_line",
"message": "message", "channel": "channel", "provider": "provider", "category": "category",
"action": "action", "outcome": "outcome", "logon_type": "logon_type",
"authentication_package": "authentication_package", "status_code": "status_code",
"failure_reason": "failure_reason", "event_code": "event_code",
}
v, ok := fields[name]
return v, ok
}
func groupExpr(name string) (string, bool) { return fieldExpr(name) }
func groupAlias(name string) string {
switch name {
case "host", "host_name":
return "host_name"
case "user", "user_name", "target_user", "subject_user":
return "user_name"
default:
return name
}
}
func compileCondition(c Condition) (string, error) {
expr, ok := fieldExpr(c.Field)
if !ok {
return "", fmt.Errorf("invalid field %q", c.Field)
}
q := clickhouse.Q(c.Value)
switch c.Operator {
case "equals":
return expr + "=" + q, nil
case "not_equals":
return expr + "!=" + q, nil
case "contains":
return "positionCaseInsensitiveUTF8(" + expr + "," + q + ")>0", nil
case "not_contains":
return "positionCaseInsensitiveUTF8(" + expr + "," + q + ")=0", nil
case "regex":
return "match(" + expr + "," + q + ")", nil
case "exists":
return expr + "!=''", nil
case "not_exists":
return expr + "=''", nil
case "in":
parts := strings.Split(c.Value, ",")
vals := make([]string, 0, len(parts))
for _, p := range parts {
if x := strings.TrimSpace(p); x != "" {
vals = append(vals, clickhouse.Q(x))
}
}
if len(vals) == 0 {
return "", fmt.Errorf("empty in condition")
}
return expr + " IN (" + strings.Join(vals, ",") + ")", nil
default:
return "", fmt.Errorf("unsupported operator %q", c.Operator)
}
}
func quoteStrings(v []string) string {
out := make([]string, 0, len(v))
for _, x := range v {
out = append(out, clickhouse.Q(x))
}
return strings.Join(out, ",")
}
func validID(s string) bool {
if s == "" || len(s) > 128 {
return false
}
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
continue
}
return false
}
return true
}
func ts(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05.000") }
func value(v any) string {
if v == nil {
return ""
}
return fmt.Sprint(v)
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}

View File

@@ -0,0 +1,26 @@
package rules
import (
"strings"
"testing"
"time"
)
func TestCompileThreshold(t *testing.T) {
r := Rule{ID: "failed", Title: "Failed", Severity: "high", Kind: "threshold", Enabled: true, EventCodes: []uint32{4625}, GroupBy: []string{"host", "user", "source_ip"}, Threshold: 20, WindowSeconds: 300, Summary: "{count} failures for {user}"}
q, _, err := Compile(r, "siem", "default", time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"event_code IN (4625)", "GROUP BY host_name", "HAVING cnt >= 20"} {
if !strings.Contains(q, want) {
t.Fatalf("query missing %q: %s", want, q)
}
}
}
func TestRejectUnknownField(t *testing.T) {
r := Rule{ID: "bad", Title: "Bad", Severity: "high", Kind: "event", EventCodes: []uint32{1}, Conditions: []Condition{{Field: "DROP TABLE", Operator: "equals", Value: "x"}}, WindowSeconds: 300}
if err := Validate(r); err == nil {
t.Fatal("expected validation error")
}
}