This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
355
internal/rules/rules.go
Normal 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
|
||||
}
|
||||
26
internal/rules/rules_test.go
Normal file
26
internal/rules/rules_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user