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")
|
||||
|
||||
Reference in New Issue
Block a user