package main import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "encoding/xml" "errors" "fmt" "html/template" "io" "log" "math" "net" "net/http" "net/url" "os" "os/signal" "strconv" "strings" "syscall" "time" _ "github.com/go-sql-driver/mysql" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) const uiTemplates = ` {{define "header"}} {{.Title}}
SIEM-lite Security Operations
{{end}} {{define "footer"}}
{{end}} {{define "privileged_users"}} {{template "header" .}}

{{.Title}}

Privilegierte Benutzer für UEBA-Regeln wie „Admin auf neuem Host“.

Benutzer hinzufügen

Privileged Users

{{range .Users}} {{end}}
Username Grund Status Created Updated Aktion
{{.Username}} {{.Reason}} {{if .Enabled}} aktiv {{else}} inaktiv {{end}} {{fmtTime .CreatedAt}} {{fmtTime .UpdatedAt}}
{{if .Enabled}} {{else}} {{end}}
{{template "footer" .}} {{end}} {{define "dashboard"}} {{template "header" .}}

{{.Title}}

Stand: {{fmtTime .Now}}

Agents gesamt
{{.Stats.AgentsTotal}}
Agents aktiv
{{.Stats.AgentsActive}}
Events 24h
{{.Stats.Events24h}}
Detections 24h
{{.Stats.Detections24h}}
High Detections 24h
{{.Stats.HighDetections24h}}
Open Detections
{{.Stats.OpenDetections}}
Investigating
{{.Stats.InvestigatingDetections}}
Critical 24h
{{.Stats.CriticalDetections24h}}
False Positive 24h
{{.Stats.FalsePositive24h}}
Legitim 24h
{{.Stats.Legitimate24h}}

Neueste Detections

{{range .RecentDetections}} {{end}}
ZeitRuleSeverityHostZusammenfassung
{{fmtTime .CreatedAt}} {{.RuleName}} {{.Severity}} {{.Hostname}} {{.Summary}}

Neueste Events

{{range .RecentEvents}} {{end}}
ZeitHostChannelEventIDUserIPNachricht
{{fmtTime .Time}} {{.Hostname}} {{.Channel}} {{.EventID}} {{if .TargetUser}}{{.TargetUser}}{{else}}{{.SubjectUser}}{{end}} {{.SrcIP}} {{short .Message 120}}
{{template "footer" .}} {{end}} {{define "detections"}} {{template "header" .}}

{{.Title}}

Open Critical High Investigating False Positives Legitim Plausibel Resolved Confirmed Incidents

Batch-Bewertung

Bearbeitet mehrere Detections anhand von Rule, Host, Channel, EventID oder Zeitfenster.

{{range .Detections}} {{end}}
Zeit Rule Severity Status Host Score Summary Bewertung Events
{{fmtTime .CreatedAt}} {{.RuleName}} {{.Severity}} {{.Status}} {{.Hostname}} {{printf "%.2f" .Score}} {{.Summary}} {{if .AnalystNote}}
Notiz: {{.AnalystNote}}
{{end}}
anzeigen
{{template "footer" .}} {{end}} {{define "rules"}} {{template "header" .}}

{{.Title}}

Dynamische Regeln für einfache EventID-, Feld- und Threshold-Erkennung.

Neue Regel

Bestehende Regeln

{{range .Rules}} {{end}}
NameSeverityChannelEventsFilterThresholdStatusAktion
{{.Name}}
{{.Description}}
{{.Severity}} {{.Channel}} {{.EventIDs}} {{.MatchField}} {{.MatchOperator}} {{.MatchValue}} {{.ThresholdCount}} / {{.ThresholdWindowSeconds}}s {{if .Enabled}} aktiv {{else}} inaktiv {{end}}
{{if .Enabled}} {{else}} {{end}}
{{template "footer" .}} {{end}} {{define "soc"}} {{template "header" .}}

{{.Title}}

Stand: {{fmtTime .Now}}

Top Host Risk Scores

{{range .TopHosts}} {{end}}
Host Risk Severity Open High Critical Confirmed Last Detection
{{.Hostname}} {{printf "%.1f" .RiskScore}} {{.Severity}} {{.OpenDetections}} {{.HighDetections}} {{.CriticalDetections}} {{.ConfirmedIncidents}} {{if .LastDetectionAt.Valid}}{{fmtTime .LastDetectionAt.Time}}{{end}}

Recent SOC Relevant Detections

{{range .RecentIncidents}} {{end}}
Zeit Rule Severity Status Host Summary
{{fmtTime .CreatedAt}} {{.RuleName}} {{.Severity}} {{.Status}} {{.Hostname}} {{.Summary}}
{{template "footer" .}} {{end}} {{define "baseline"}} {{template "header" .}}

{{.Title}}

Baseline-Anomalien aus der Regel baseline_event_rate_anomaly.

{{range .Anomalies}} {{end}}
Zeit Host Channel EventID Severity Aktuell Baseline Z-Score Samples Bucket
{{fmtTime .CreatedAt}} {{.Hostname}} {{.Channel}} {{.EventID}} {{.Severity}} {{.Count}} {{printf "%.2f" .AvgCount}} ± {{printf "%.2f" .StddevCount}} {{printf "%.2f" .ZScore}} {{.SampleCount}} Tag {{.DayOfWeek}}, Stunde {{.HourOfDay}}
{{template "footer" .}} {{end}} {{define "events"}} {{template "header" .}}

{{.Title}}

{{range .Events}} {{end}}
ZeitHostChannelEventIDTarget UserSubject UserIPWorkstationDetail
{{fmtTime .Time}} {{.Hostname}} {{.Channel}} {{.EventID}} {{.TargetUser}} {{.SubjectUser}} {{.SrcIP}} {{.Workstation}} öffnen
{{template "footer" .}} {{end}} {{define "agents"}} {{template "header" .}}

{{.Title}}

Stand: {{fmtTime .Now}}

{{range .Agents}} {{end}}
Hostname Status Aktiviert First Seen Last Seen Offline Minuten Last IP Aktion
{{.Hostname}} {{if .IsOnline}} online {{else}} offline {{end}} {{if .IsEnabled}} aktiv {{else}} inaktiv {{end}} {{fmtTime .FirstSeen}} {{fmtTime .LastSeen}} {{.OfflineMinutes}} {{.LastIP}}
{{if .IsEnabled}} {{else}} {{end}}
{{template "footer" .}} {{end}} {{define "event_detail"}} {{template "header" .}}

{{.Title}}

Host
{{.Event.Hostname}}
Channel
{{.Event.Channel}}
EventID
{{.Event.EventID}}
Zeit
{{fmtTime .Event.Time}}
Target User
{{.Event.TargetUser}}
Subject User
{{.Event.SubjectUser}}
Source IP
{{.Event.SrcIP}}
Workstation
{{.Event.Workstation}}
Logon Type
{{.Event.LogonType}}
Process
{{.Event.ProcessName}}
Status
{{.Event.StatusText}}
SubStatus
{{.Event.SubStatusText}}

Rohes Event XML

{{.Event.Message}}
{{template "footer" .}} {{end}} ` type Config struct { ListenAddr string DBDSN string MaxBodyBytes int64 HTTPReadTimeout time.Duration HTTPWriteTimeout time.Duration HTTPIdleTimeout time.Duration DBMaxOpenConns int DBMaxIdleConns int DBConnMaxLifetime time.Duration DBConnMaxIdleTime time.Duration DetectionInterval time.Duration OfflineAfter time.Duration OfflineAlertMax time.Duration FailedLogonWindow time.Duration FailedLogonThreshold int RebootWindow time.Duration RebootThreshold int PasswordSprayWindow time.Duration PasswordSprayMinUsers int PasswordSprayMinAttempts int SuccessAfterFailureWindow time.Duration NewSourceIPLookback time.Duration NewSourceIPWindow time.Duration DetectionsLimit int EnrollmentKey string BaselineEnabled bool BaselineWindow time.Duration BaselineMinSamples int BaselineMinCount int BaselineMediumZScore float64 BaselineHighZScore float64 BaselineSuppressFor time.Duration Timezone string UEBAEnabled bool UEBALookback time.Duration UEBANewContextWindow time.Duration RiskScoreWindow time.Duration } type LogPayload struct { Hostname string `json:"host"` Channel string `json:"channel"` EventID uint32 `json:"id"` Source string `json:"source"` Time time.Time `json:"ts"` Message string `json:"msg"` } type NormalizedEvent struct { Computer string ProviderName string LevelValue uint32 TaskValue uint32 OpcodeValue uint32 Keywords string TargetUser string TargetDomain string SubjectUser string SubjectDomain string Workstation string SrcIP string SrcPort string LogonType string ProcessName string AuthenticationPackage string LogonProcess string StatusText string SubStatusText string FailureReason string MemberName string GroupName string } type Detection struct { ID uint64 `json:"id"` RuleName string `json:"rule_name"` Severity string `json:"severity"` Hostname string `json:"hostname"` Channel string `json:"channel"` EventID uint32 `json:"event_id"` Score float64 `json:"score"` WindowStart time.Time `json:"window_start"` WindowEnd time.Time `json:"window_end"` Summary string `json:"summary"` Details json.RawMessage `json:"details_json"` CreatedAt time.Time `json:"created_at"` Status string AnalystNote string ReviewedBy string ReviewedAt sql.NullTime IsFalsePositive bool IsLegitimate bool } type ingestResponse struct { Accepted int `json:"accepted"` } type server struct { db *sql.DB logger *log.Logger cfg Config registry *prometheus.Registry detector *detector startTime time.Time templates *template.Template location *time.Location } type detector struct { db *sql.DB cfg Config logger *log.Logger lastSeenGauge *prometheus.GaugeVec activeAgentsGauge prometheus.Gauge anomalyScoreGauge *prometheus.GaugeVec detectionHitsTotal *prometheus.CounterVec ruleLastRunGauge *prometheus.GaugeVec ruleRuntimeHist *prometheus.HistogramVec ruleErrorsTotal *prometheus.CounterVec baselineCurrentCountGauge *prometheus.GaugeVec baselineAverageGauge *prometheus.GaugeVec baselineStddevGauge *prometheus.GaugeVec baselineSamplesGauge *prometheus.GaugeVec hostRiskScoreGauge *prometheus.GaugeVec privilegedLogonsTotal *prometheus.CounterVec privilegedLogonFailuresTotal *prometheus.CounterVec privilegedNewHostTotal *prometheus.CounterVec } type EventRow struct { ID uint64 Hostname string Channel string EventID uint32 Source string Computer string ProviderName string TargetUser string TargetDomain string SubjectUser string SubjectDomain string Workstation string SrcIP string SrcPort string LogonType string ProcessName string AuthenticationPackage string LogonProcess string StatusText string SubStatusText string FailureReason string Time time.Time ReceivedAt time.Time Message string } type DashboardStats struct { AgentsTotal int AgentsActive int Events24h int64 Detections24h int64 HighDetections24h int64 OpenDetections int64 InvestigatingDetections int64 CriticalDetections24h int64 FalsePositive24h int64 Legitimate24h int64 } type DashboardPageData struct { Title string Now time.Time Stats DashboardStats RecentDetections []Detection RecentEvents []EventRow } type DetectionListPageData struct { Title string Now time.Time Filters map[string]string Detections []Detection } type EventListPageData struct { Title string Now time.Time Filters map[string]string Events []EventRow } type EventDetailPageData struct { Title string Now time.Time Event EventRow } type AgentRow struct { ID uint64 Hostname string FirstSeen time.Time LastSeen time.Time LastIP string IsEnabled bool IsOnline bool OfflineMinutes int } type AgentListPageData struct { Title string Now time.Time Agents []AgentRow } type DynamicRule struct { ID uint64 Name string Description string Severity string Channel string EventIDs string MatchField string MatchOperator string MatchValue string ThresholdCount int ThresholdWindowSeconds int SuppressForSeconds int Enabled bool CreatedAt time.Time UpdatedAt time.Time } type DynamicRulePageData struct { Title string Now time.Time Rules []DynamicRule } type BaselineBucket struct { Hostname string Channel string EventID uint32 Hour int DayOfWeek int Count int } type BaselineStat struct { AvgCount float64 M2Count float64 StddevCount float64 SampleCount int } type BaselineAnomalyRow struct { ID uint64 CreatedAt time.Time Hostname string Channel string EventID uint32 Severity string Score float64 WindowStart time.Time WindowEnd time.Time Summary string Count int AvgCount float64 StddevCount float64 ZScore float64 SampleCount int HourOfDay int DayOfWeek int WindowMin int } type BaselinePageData struct { Title string Now time.Time Filters map[string]string Anomalies []BaselineAnomalyRow } type baselineDetailsJSON struct { Hostname string `json:"hostname"` Channel string `json:"channel"` EventID uint32 `json:"event_id"` Count int `json:"count"` AvgCount float64 `json:"avg_count"` StddevCount float64 `json:"stddev_count"` ZScore float64 `json:"z_score"` SampleCount int `json:"sample_count"` HourOfDay int `json:"hour_of_day"` DayOfWeek int `json:"day_of_week"` WindowMinutes int `json:"window_minutes"` } type SOCHostRiskRow struct { Hostname string RiskScore float64 Severity string OpenDetections int HighDetections int CriticalDetections int ConfirmedIncidents int LastDetectionAt sql.NullTime UpdatedAt time.Time } type SOCRecentIncidentRow struct { ID uint64 CreatedAt time.Time RuleName string Severity string Status string Hostname string Summary string } type SOCPageData struct { Title string Now time.Time TopHosts []SOCHostRiskRow RecentIncidents []SOCRecentIncidentRow } type PrivilegedUserRow struct { Username string Reason string Enabled bool CreatedAt time.Time UpdatedAt time.Time } type PrivilegedUsersPageData struct { Title string Now time.Time Users []PrivilegedUserRow } var ( httpRequestsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{Name: "eventcollector_http_requests_total", Help: "Total HTTP requests."}, []string{"path", "method", "status"}, ) httpRequestDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{Name: "eventcollector_http_request_duration_seconds", Help: "HTTP request latency.", Buckets: prometheus.DefBuckets}, []string{"path", "method", "status"}, ) ingestBatchesTotal = prometheus.NewCounter( prometheus.CounterOpts{Name: "eventcollector_ingest_batches_total", Help: "Total ingested batches."}, ) ingestEventsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{Name: "eventcollector_ingest_events_total", Help: "Total ingested events."}, []string{"channel", "event_id"}, ) ingestRejectedTotal = prometheus.NewCounterVec( prometheus.CounterOpts{Name: "eventcollector_ingest_rejected_total", Help: "Rejected ingest requests."}, []string{"reason"}, ) dbInsertEventsTotal = prometheus.NewCounter( prometheus.CounterOpts{Name: "eventcollector_db_insert_events_total", Help: "Total inserted events into database."}, ) dbInsertFailuresTotal = prometheus.NewCounter( prometheus.CounterOpts{Name: "eventcollector_db_insert_failures_total", Help: "Failed database insert operations."}, ) dbBatchSizeHist = prometheus.NewHistogram( prometheus.HistogramOpts{Name: "eventcollector_db_batch_size", Help: "Batch sizes written to database.", Buckets: []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000}}, ) dbTxDurationHist = prometheus.NewHistogram( prometheus.HistogramOpts{Name: "eventcollector_db_tx_duration_seconds", Help: "Database transaction duration.", Buckets: prometheus.DefBuckets}, ) ) func main() { cfg := loadConfig() logger := log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds|log.LUTC) loc, err := time.LoadLocation(cfg.Timezone) if err != nil { logger.Printf("invalid TZ %q, falling back to Local: %v", cfg.Timezone, err) loc = time.Local } db, err := sql.Open("mysql", cfg.DBDSN) if err != nil { logger.Fatalf("sql.Open: %v", err) } db.SetMaxOpenConns(cfg.DBMaxOpenConns) db.SetMaxIdleConns(cfg.DBMaxIdleConns) db.SetConnMaxLifetime(cfg.DBConnMaxLifetime) db.SetConnMaxIdleTime(cfg.DBConnMaxIdleTime) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { logger.Fatalf("db.PingContext: %v", err) } reg := prometheus.NewRegistry() reg.MustRegister( httpRequestsTotal, httpRequestDuration, ingestBatchesTotal, ingestEventsTotal, ingestRejectedTotal, dbInsertEventsTotal, dbInsertFailuresTotal, dbBatchSizeHist, dbTxDurationHist, ) d := &detector{ db: db, cfg: cfg, logger: logger, lastSeenGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{Name: "eventcollector_agent_last_seen_unixtime", Help: "Unix time when agent was last seen."}, []string{"host"}, ), activeAgentsGauge: prometheus.NewGauge( prometheus.GaugeOpts{Name: "eventcollector_active_agents", Help: "Number of active agents seen within offline threshold."}, ), anomalyScoreGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{Name: "eventcollector_anomaly_score", Help: "Current anomaly score per host and rule."}, []string{"host", "rule"}, ), detectionHitsTotal: prometheus.NewCounterVec( prometheus.CounterOpts{Name: "eventcollector_detection_hits_total", Help: "Total number of detections per rule and severity."}, []string{"rule", "severity"}, ), ruleLastRunGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{Name: "eventcollector_rule_last_run_unixtime", Help: "Unix time of the last successful rule run."}, []string{"rule"}, ), ruleRuntimeHist: prometheus.NewHistogramVec( prometheus.HistogramOpts{Name: "eventcollector_rule_runtime_seconds", Help: "Rule runtime duration.", Buckets: prometheus.DefBuckets}, []string{"rule"}, ), ruleErrorsTotal: prometheus.NewCounterVec( prometheus.CounterOpts{Name: "eventcollector_rule_errors_total", Help: "Rule execution errors."}, []string{"rule"}, ), baselineCurrentCountGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "eventcollector_baseline_current_count", Help: "Current event count in baseline window.", }, []string{"host", "channel", "event_id"}, ), baselineAverageGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "eventcollector_baseline_avg_count", Help: "Baseline average event count.", }, []string{"host", "channel", "event_id"}, ), baselineStddevGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "eventcollector_baseline_stddev_count", Help: "Baseline standard deviation event count.", }, []string{"host", "channel", "event_id"}, ), baselineSamplesGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "eventcollector_baseline_sample_count", Help: "Baseline sample count.", }, []string{"host", "channel", "event_id"}, ), hostRiskScoreGauge: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "eventcollector_host_risk_score", Help: "Calculated risk score per host.", }, []string{"host", "severity"}, ), privilegedLogonsTotal: prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "siem_privileged_logons_total", Help: "Successful logons by privileged users.", }, []string{"user", "host"}, ), privilegedLogonFailuresTotal: prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "siem_privileged_logon_failures_total", Help: "Failed logons by privileged users.", }, []string{"user", "host"}, ), privilegedNewHostTotal: prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "siem_privileged_new_host_total", Help: "Privileged user logged on to a new host.", }, []string{"user", "host"}, ), } reg.MustRegister( d.lastSeenGauge, d.activeAgentsGauge, d.anomalyScoreGauge, d.detectionHitsTotal, d.ruleLastRunGauge, d.ruleRuntimeHist, d.ruleErrorsTotal, d.baselineCurrentCountGauge, d.baselineAverageGauge, d.baselineStddevGauge, d.baselineSamplesGauge, d.hostRiskScoreGauge, d.privilegedLogonsTotal, d.privilegedLogonFailuresTotal, d.privilegedNewHostTotal, ) s := &server{ db: db, logger: logger, cfg: cfg, registry: reg, detector: d, startTime: time.Now().UTC(), location: loc, } tmpl := template.Must(template.New("ui").Funcs(template.FuncMap{ "q": url.QueryEscape, "fmtTime": func(t time.Time) string { if t.IsZero() { return "" } return t.In(loc).Format("2006-01-02 15:04:05 MST") }, "short": func(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }, "eq": func(a, b string) bool { return a == b }, }).Parse(uiTemplates)) s.templates = tmpl go s.runDetectionLoop() mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealthz) mux.HandleFunc("/readyz", s.handleReadyz) mux.HandleFunc("/ingest", s.handleIngest) mux.HandleFunc("/detections", s.handleDetections) mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{})) mux.HandleFunc("/ui", s.handleUIIndex) mux.HandleFunc("/ui/detections", s.handleUIDetections) mux.HandleFunc("/ui/detection/update", s.handleUIDetectionUpdate) mux.HandleFunc("/ui/events", s.handleUIEvents) mux.HandleFunc("/ui/event", s.handleUIEventDetail) mux.HandleFunc("/ui/agents", s.handleUIAgents) mux.HandleFunc("/ui/agents/toggle", s.handleUIAgentToggle) mux.HandleFunc("/ui/rules", s.handleUIRules) mux.HandleFunc("/ui/rules/save", s.handleUIRuleSave) mux.HandleFunc("/ui/rules/toggle", s.handleUIRuleToggle) mux.HandleFunc("/ui/baseline", s.handleUIBaseline) mux.HandleFunc("/ui/soc", s.handleUISOC) mux.HandleFunc("/ui/detections/batch-update", s.handleUIDetectionsBatchUpdate) mux.HandleFunc("/ui/privileged-users", s.handleUIPrivilegedUsers) mux.HandleFunc("/ui/privileged-users/save", s.handleUIPrivilegedUserSave) mux.HandleFunc("/ui/privileged-users/toggle", s.handleUIPrivilegedUserToggle) httpSrv := &http.Server{ Addr: cfg.ListenAddr, Handler: metricsMiddleware(logger, recoveryMiddleware(mux)), ReadTimeout: cfg.HTTPReadTimeout, WriteTimeout: cfg.HTTPWriteTimeout, IdleTimeout: cfg.HTTPIdleTimeout, } go func() { logger.Printf("listening on %s", cfg.ListenAddr) if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Fatalf("ListenAndServe: %v", err) } }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop logger.Println("shutdown requested") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) defer shutdownCancel() if err := httpSrv.Shutdown(shutdownCtx); err != nil { logger.Printf("http shutdown error: %v", err) } if err := db.Close(); err != nil { logger.Printf("db close error: %v", err) } } func (s *server) listPrivilegedUsers(ctx context.Context) ([]PrivilegedUserRow, error) { rows, err := s.db.QueryContext(ctx, ` SELECT username, COALESCE(reason, ''), enabled, created_at, updated_at FROM privileged_users ORDER BY username ASC `) if err != nil { return nil, err } defer rows.Close() var out []PrivilegedUserRow for rows.Next() { var u PrivilegedUserRow if err := rows.Scan(&u.Username, &u.Reason, &u.Enabled, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, err } out = append(out, u) } return out, rows.Err() } func (s *server) handleUIPrivilegedUsers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() users, err := s.listPrivilegedUsers(ctx) if err != nil { s.logger.Printf("privileged users: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } s.renderTemplate(w, "privileged_users", PrivilegedUsersPageData{ Title: "Privileged Users", Now: time.Now().In(s.location), Users: users, }) } func (s *server) handleUIPrivilegedUserSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } username := normalizeUsername(r.FormValue("username")) reason := strings.TrimSpace(r.FormValue("reason")) if username == "" || strings.HasSuffix(username, "$") { writeError(w, http.StatusBadRequest, "invalid username") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() _, err := s.db.ExecContext(ctx, ` INSERT INTO privileged_users (username, reason, enabled) VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE reason = VALUES(reason), enabled = 1, updated_at = UTC_TIMESTAMP(6) `, username, reason) if err != nil { s.logger.Printf("save privileged user: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } http.Redirect(w, r, "/ui/privileged-users", http.StatusSeeOther) } func (s *server) handleUIPrivilegedUserToggle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } username := normalizeUsername(r.FormValue("username")) enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" if username == "" { writeError(w, http.StatusBadRequest, "invalid username") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() _, err := s.db.ExecContext(ctx, ` UPDATE privileged_users SET enabled = ?, updated_at = UTC_TIMESTAMP(6) WHERE username = ? `, enabled, username) if err != nil { s.logger.Printf("toggle privileged user: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } http.Redirect(w, r, "/ui/privileged-users", http.StatusSeeOther) } func (s *server) handleUIDetectionsBatchUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } status := strings.TrimSpace(r.FormValue("status")) note := strings.TrimSpace(r.FormValue("note")) reviewedBy := strings.TrimSpace(r.FormValue("reviewed_by")) if reviewedBy == "" { reviewedBy = "ui-batch" } switch status { case "open", "acknowledged", "investigating", "legitimate", "plausible", "false_positive", "resolved", "suppressed", "confirmed_incident": default: writeError(w, http.StatusBadRequest, "invalid status") return } rule := strings.TrimSpace(r.FormValue("rule")) host := strings.TrimSpace(r.FormValue("host")) channel := strings.TrimSpace(r.FormValue("channel")) var eventID uint32 if v := strings.TrimSpace(r.FormValue("event_id")); v != "" { n, err := strconv.ParseUint(v, 10, 32) if err != nil { writeError(w, http.StatusBadRequest, "invalid event_id") return } eventID = uint32(n) } from := strings.TrimSpace(r.FormValue("from")) to := strings.TrimSpace(r.FormValue("to")) limit := atoiDefault(r.FormValue("limit"), 5000) if limit <= 0 || limit > 50000 { limit = 5000 } if rule == "" && host == "" && channel == "" && eventID == 0 && from == "" && to == "" { writeError(w, http.StatusBadRequest, "at least one filter required") return } isFalsePositive := status == "false_positive" isLegitimate := status == "legitimate" || status == "plausible" query := ` UPDATE detections SET status = ?, analyst_note = ?, reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(6), is_false_positive = ?, is_legitimate = ? WHERE id IN ( SELECT id FROM ( SELECT id FROM detections WHERE 1=1 ` args := []any{ status, note, reviewedBy, isFalsePositive, isLegitimate, } if rule != "" { query += " AND rule_name = ?" args = append(args, rule) } if host != "" { query += " AND hostname = ?" args = append(args, host) } if channel != "" { query += " AND channel_name = ?" args = append(args, channel) } if eventID != 0 { query += " AND event_id = ?" args = append(args, eventID) } if from != "" { if t, err := parseUIRFC3339(from); err == nil { query += " AND created_at >= ?" args = append(args, t.UTC()) } } if to != "" { if t, err := parseUIRFC3339(to); err == nil { query += " AND created_at <= ?" args = append(args, t.UTC()) } } query += ` ORDER BY created_at DESC LIMIT ? ) x ) ` args = append(args, limit) ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() res, err := s.db.ExecContext(ctx, query, args...) if err != nil { s.logger.Printf("batch update detections: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } affected, _ := res.RowsAffected() http.Redirect(w, r, fmt.Sprintf("/ui/detections?status=%s&updated=%d", url.QueryEscape(status), affected), http.StatusSeeOther) } func (s *server) listSOCTopHosts(ctx context.Context, limit int) ([]SOCHostRiskRow, error) { rows, err := s.db.QueryContext(ctx, ` SELECT hostname, risk_score, severity, open_detections, high_detections, critical_detections, confirmed_incidents, last_detection_at, updated_at FROM host_risk_scores ORDER BY risk_score DESC LIMIT ? `, limit) if err != nil { return nil, err } defer rows.Close() var out []SOCHostRiskRow for rows.Next() { var r SOCHostRiskRow if err := rows.Scan( &r.Hostname, &r.RiskScore, &r.Severity, &r.OpenDetections, &r.HighDetections, &r.CriticalDetections, &r.ConfirmedIncidents, &r.LastDetectionAt, &r.UpdatedAt, ); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func (s *server) listSOCRecentIncidents(ctx context.Context, limit int) ([]SOCRecentIncidentRow, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, created_at, rule_name, severity, status, hostname, summary FROM detections WHERE status IN ('open', 'investigating', 'confirmed_incident') OR severity IN ('high', 'critical') ORDER BY created_at DESC LIMIT ? `, limit) if err != nil { return nil, err } defer rows.Close() var out []SOCRecentIncidentRow for rows.Next() { var r SOCRecentIncidentRow if err := rows.Scan( &r.ID, &r.CreatedAt, &r.RuleName, &r.Severity, &r.Status, &r.Hostname, &r.Summary, ); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func (s *server) handleUISOC(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() topHosts, err := s.listSOCTopHosts(ctx, 20) if err != nil { s.logger.Printf("soc top hosts: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } incidents, err := s.listSOCRecentIncidents(ctx, 50) if err != nil { s.logger.Printf("soc recent incidents: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } s.renderTemplate(w, "soc", SOCPageData{ Title: "SOC Dashboard", Now: time.Now(), TopHosts: topHosts, RecentIncidents: incidents, }) } func (s *server) createBaselineExclusionFromDetection(ctx context.Context, det Detection, reason, createdBy string, hours int) error { var expiresAt any = nil if hours > 0 { expiresAt = time.Now().UTC().Add(time.Duration(hours) * time.Hour) } _, err := s.db.ExecContext(ctx, ` INSERT INTO baseline_exclusions (hostname, channel_name, event_id, reason, created_by, expires_at, enabled) VALUES (?, ?, ?, ?, ?, ?, 1) `, det.Hostname, det.Channel, det.EventID, reason, createdBy, expiresAt, ) return err } func (s *server) handleUIDetectionUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } id, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("id")), 10, 64) if err != nil || id == 0 { writeError(w, http.StatusBadRequest, "invalid id") return } status := strings.TrimSpace(r.FormValue("status")) note := strings.TrimSpace(r.FormValue("note")) reviewedBy := strings.TrimSpace(r.FormValue("reviewed_by")) if reviewedBy == "" { reviewedBy = "ui" } switch status { case "open", "acknowledged", "investigating", "legitimate", "plausible", "false_positive", "resolved", "suppressed", "confirmed_incident": default: writeError(w, http.StatusBadRequest, "invalid status") return } isFalsePositive := status == "false_positive" isLegitimate := status == "legitimate" ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() _, err = s.db.ExecContext(ctx, ` UPDATE detections SET status = ?, analyst_note = ?, reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(6), is_false_positive = ?, is_legitimate = ? WHERE id = ? `, status, note, reviewedBy, isFalsePositive, isLegitimate, id, ) if err != nil { s.logger.Printf("update detection: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } createSuppression := strings.TrimSpace(r.FormValue("create_suppression")) == "1" if createSuppression && (status == "false_positive" || status == "legitimate" || status == "suppressed") { det, err := s.getDetectionByID(ctx, id) if err != nil { s.logger.Printf("get detection for suppression: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } hours := atoiDefault(r.FormValue("suppress_hours"), 24) reason := note if reason == "" { reason = fmt.Sprintf("Suppression via UI wegen Status %s", status) } if err := s.createSuppressionFromDetection(ctx, det, reason, reviewedBy, hours); err != nil { s.logger.Printf("create suppression: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } } baselineAction := strings.TrimSpace(r.FormValue("baseline_action")) if baselineAction != "" { det, err := s.getDetectionByID(ctx, id) if err != nil { s.logger.Printf("get detection for baseline exclusion: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } hours := 0 switch baselineAction { case "exclude_24h": hours = 24 case "exclude_7d": hours = 168 case "exclude_30d": hours = 720 case "exclude_forever": hours = 0 default: writeError(w, http.StatusBadRequest, "invalid baseline action") return } reason := note if reason == "" { reason = fmt.Sprintf("Baseline exclusion via UI wegen Status %s", status) } if err := s.createBaselineExclusionFromDetection(ctx, det, reason, reviewedBy, hours); err != nil { s.logger.Printf("create baseline exclusion: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } } if status == "confirmed_incident" && baselineAction == "" { det, err := s.getDetectionByID(ctx, id) if err != nil { s.logger.Printf("get detection for confirmed incident exclusion: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } reason := note if reason == "" { reason = "Confirmed incident: nicht in Baseline einlernen" } if err := s.createBaselineExclusionFromDetection(ctx, det, reason, reviewedBy, 168); err != nil { s.logger.Printf("create confirmed incident baseline exclusion: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } } redirect := strings.TrimSpace(r.FormValue("redirect")) if redirect == "" { redirect = "/ui/detections" } http.Redirect(w, r, redirect, http.StatusSeeOther) } func (d *detector) isBaselineExcluded(ctx context.Context, hostname, channel string, eventID uint32) (bool, error) { var count int err := d.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM baseline_exclusions WHERE enabled = 1 AND (hostname = '' OR hostname = ?) AND (channel_name = '' OR channel_name = ?) AND (event_id = 0 OR event_id = ?) AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP(6)) `, hostname, channel, eventID, ).Scan(&count) if err != nil { return false, err } return count > 0, nil } func (s *server) getDetectionByID(ctx context.Context, id uint64) (Detection, error) { const q = ` SELECT id, rule_name, severity, hostname, channel_name, event_id, score, window_start, window_end, summary, details_json, created_at, status, COALESCE(analyst_note, ''), COALESCE(reviewed_by, ''), reviewed_at, is_false_positive, is_legitimate FROM detections WHERE id = ? LIMIT 1 ` var d Detection err := s.db.QueryRowContext(ctx, q, id).Scan( &d.ID, &d.RuleName, &d.Severity, &d.Hostname, &d.Channel, &d.EventID, &d.Score, &d.WindowStart, &d.WindowEnd, &d.Summary, &d.Details, &d.CreatedAt, &d.Status, &d.AnalystNote, &d.ReviewedBy, &d.ReviewedAt, &d.IsFalsePositive, &d.IsLegitimate, ) return d, err } func (s *server) createSuppressionFromDetection(ctx context.Context, det Detection, reason, createdBy string, hours int) error { var expiresAt any = nil if hours > 0 { expiresAt = time.Now().UTC().Add(time.Duration(hours) * time.Hour) } _, err := s.db.ExecContext(ctx, ` INSERT INTO detection_suppressions (rule_name, hostname, channel_name, event_id, reason, created_by, expires_at, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, 1) `, det.RuleName, det.Hostname, det.Channel, det.EventID, reason, createdBy, expiresAt, ) return err } func (s *server) listBaselineAnomalies(ctx context.Context, host, channel, severity string, eventID uint32, limit int) ([]BaselineAnomalyRow, error) { if limit <= 0 || limit > 1000 { limit = 100 } query := ` SELECT id, severity, hostname, channel_name, event_id, score, window_start, window_end, summary, details_json, created_at FROM detections WHERE rule_name = 'baseline_event_rate_anomaly' ` args := make([]any, 0, 8) if host != "" { query += ` AND hostname = ?` args = append(args, host) } if channel != "" { query += ` AND channel_name = ?` args = append(args, channel) } if eventID != 0 { query += ` AND event_id = ?` args = append(args, eventID) } if severity != "" { query += ` AND severity = ?` args = append(args, severity) } query += ` ORDER BY created_at DESC LIMIT ?` args = append(args, limit) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() out := make([]BaselineAnomalyRow, 0) for rows.Next() { var row BaselineAnomalyRow var detailsRaw []byte if err := rows.Scan( &row.ID, &row.Severity, &row.Hostname, &row.Channel, &row.EventID, &row.Score, &row.WindowStart, &row.WindowEnd, &row.Summary, &detailsRaw, &row.CreatedAt, ); err != nil { return nil, err } var details baselineDetailsJSON if err := json.Unmarshal(detailsRaw, &details); err == nil { row.Count = details.Count row.AvgCount = details.AvgCount row.StddevCount = details.StddevCount row.ZScore = details.ZScore row.SampleCount = details.SampleCount row.HourOfDay = details.HourOfDay row.DayOfWeek = details.DayOfWeek row.WindowMin = details.WindowMinutes } else { row.ZScore = row.Score } out = append(out, row) } return out, rows.Err() } func (s *server) handleUIBaseline(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } filters := map[string]string{ "host": strings.TrimSpace(r.URL.Query().Get("host")), "channel": strings.TrimSpace(r.URL.Query().Get("channel")), "event_id": strings.TrimSpace(r.URL.Query().Get("event_id")), "severity": strings.TrimSpace(r.URL.Query().Get("severity")), "limit": strings.TrimSpace(r.URL.Query().Get("limit")), } limit := 100 if filters["limit"] != "" { if n, err := strconv.Atoi(filters["limit"]); err == nil && n > 0 && n <= 1000 { limit = n } } var eventID uint32 if filters["event_id"] != "" { if n, err := strconv.ParseUint(filters["event_id"], 10, 32); err == nil { eventID = uint32(n) } } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() items, err := s.listBaselineAnomalies( ctx, filters["host"], filters["channel"], filters["severity"], eventID, limit, ) if err != nil { s.logger.Printf("ui baseline: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } data := BaselinePageData{ Title: "Baseline-Anomalien", Now: time.Now(), Filters: filters, Anomalies: items, } s.renderTemplate(w, "baseline", data) } func (s *server) listDynamicRules(ctx context.Context) ([]DynamicRule, error) { const q = ` SELECT id, name, COALESCE(description, ''), severity, channel, event_ids, COALESCE(match_field, ''), COALESCE(match_operator, ''), COALESCE(match_value, ''), threshold_count, threshold_window_seconds, suppress_for_seconds, enabled, created_at, updated_at FROM detection_rules ORDER BY name ASC ` rows, err := s.db.QueryContext(ctx, q) if err != nil { return nil, err } defer rows.Close() var out []DynamicRule for rows.Next() { var r DynamicRule if err := rows.Scan( &r.ID, &r.Name, &r.Description, &r.Severity, &r.Channel, &r.EventIDs, &r.MatchField, &r.MatchOperator, &r.MatchValue, &r.ThresholdCount, &r.ThresholdWindowSeconds, &r.SuppressForSeconds, &r.Enabled, &r.CreatedAt, &r.UpdatedAt, ); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func (s *server) handleUIRules(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() rules, err := s.listDynamicRules(ctx) if err != nil { s.logger.Printf("ui rules: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } s.renderTemplate(w, "rules", DynamicRulePageData{ Title: "Dynamic Rules", Now: time.Now(), Rules: rules, }) } func (s *server) handleUIRuleToggle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } id, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("id")), 10, 64) if err != nil || id == 0 { writeError(w, http.StatusBadRequest, "invalid id") return } enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() _, err = s.db.ExecContext(ctx, ` UPDATE detection_rules SET enabled = ? WHERE id = ? `, enabled, id) if err != nil { s.logger.Printf("toggle rule: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } http.Redirect(w, r, "/ui/rules", http.StatusSeeOther) } func (s *server) handleUIRuleSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } rule := DynamicRule{ Name: strings.TrimSpace(r.FormValue("name")), Description: strings.TrimSpace(r.FormValue("description")), Severity: strings.TrimSpace(r.FormValue("severity")), Channel: strings.TrimSpace(r.FormValue("channel")), EventIDs: strings.TrimSpace(r.FormValue("event_ids")), MatchField: strings.TrimSpace(r.FormValue("match_field")), MatchOperator: strings.TrimSpace(r.FormValue("match_operator")), MatchValue: strings.TrimSpace(r.FormValue("match_value")), ThresholdCount: atoiDefault(r.FormValue("threshold_count"), 1), ThresholdWindowSeconds: atoiDefault(r.FormValue("threshold_window_seconds"), 0), SuppressForSeconds: atoiDefault(r.FormValue("suppress_for_seconds"), 3600), } if rule.Name == "" || rule.EventIDs == "" { writeError(w, http.StatusBadRequest, "name and event_ids required") return } if rule.Severity == "" { rule.Severity = "medium" } if rule.Channel == "" { rule.Channel = "Security" } if rule.ThresholdCount <= 0 { rule.ThresholdCount = 1 } if rule.SuppressForSeconds < 0 { rule.SuppressForSeconds = 0 } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() _, err := s.db.ExecContext(ctx, ` INSERT INTO detection_rules (name, description, severity, channel, event_ids, match_field, match_operator, match_value, threshold_count, threshold_window_seconds, suppress_for_seconds, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) ON DUPLICATE KEY UPDATE description = VALUES(description), severity = VALUES(severity), channel = VALUES(channel), event_ids = VALUES(event_ids), match_field = VALUES(match_field), match_operator = VALUES(match_operator), match_value = VALUES(match_value), threshold_count = VALUES(threshold_count), threshold_window_seconds = VALUES(threshold_window_seconds), suppress_for_seconds = VALUES(suppress_for_seconds), enabled = VALUES(enabled) `, rule.Name, rule.Description, rule.Severity, rule.Channel, rule.EventIDs, rule.MatchField, rule.MatchOperator, rule.MatchValue, rule.ThresholdCount, rule.ThresholdWindowSeconds, rule.SuppressForSeconds, ) if err != nil { s.logger.Printf("save rule: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } http.Redirect(w, r, "/ui/rules", http.StatusSeeOther) } func atoiDefault(v string, def int) int { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { return def } return n } func (s *server) listAgents(ctx context.Context) ([]AgentRow, error) { const q = ` SELECT id, hostname, first_seen, last_seen, last_ip, is_enabled FROM agents ORDER BY hostname ASC ` rows, err := s.db.QueryContext(ctx, q) if err != nil { return nil, err } defer rows.Close() now := time.Now().UTC() out := make([]AgentRow, 0) for rows.Next() { var a AgentRow if err := rows.Scan( &a.ID, &a.Hostname, &a.FirstSeen, &a.LastSeen, &a.LastIP, &a.IsEnabled, ); err != nil { return nil, err } if !a.LastSeen.IsZero() { a.OfflineMinutes = int(now.Sub(a.LastSeen.UTC()).Minutes()) a.IsOnline = a.IsEnabled && now.Sub(a.LastSeen.UTC()) <= s.cfg.OfflineAfter } out = append(out, a) } return out, rows.Err() } func (s *server) handleUIAgents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() agents, err := s.listAgents(ctx) if err != nil { s.logger.Printf("ui agents: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } data := AgentListPageData{ Title: "Agents", Now: time.Now(), Agents: agents, } s.renderTemplate(w, "agents", data) } func (s *server) handleUIAgentToggle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if err := r.ParseForm(); err != nil { writeError(w, http.StatusBadRequest, "invalid form") return } id, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("id")), 10, 64) if err != nil || id == 0 { writeError(w, http.StatusBadRequest, "invalid id") return } enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() res, err := s.db.ExecContext(ctx, ` UPDATE agents SET is_enabled = ? WHERE id = ? `, enabled, id) if err != nil { s.logger.Printf("toggle agent: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } affected, _ := res.RowsAffected() if affected == 0 { http.NotFound(w, r) return } http.Redirect(w, r, "/ui/agents", http.StatusSeeOther) } func (s *server) handleUIIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() stats, err := s.getDashboardStats(ctx) if err != nil { s.logger.Printf("dashboard stats: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } dets, err := s.listDetections(ctx, "", "", "", "", 20) if err != nil { s.logger.Printf("dashboard detections: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } events, err := s.listEvents(ctx, EventFilter{Limit: 20}) if err != nil { s.logger.Printf("dashboard events: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } data := DashboardPageData{ Title: "SIEM-lite Dashboard", Now: time.Now(), Stats: stats, RecentDetections: dets, RecentEvents: events, } s.renderTemplate(w, "dashboard", data) } func (s *server) handleUIDetections(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } filters := map[string]string{ "host": strings.TrimSpace(r.URL.Query().Get("host")), "rule": strings.TrimSpace(r.URL.Query().Get("rule")), "severity": strings.TrimSpace(r.URL.Query().Get("severity")), "status": strings.TrimSpace(r.URL.Query().Get("status")), "limit": strings.TrimSpace(r.URL.Query().Get("limit")), } limit := s.cfg.DetectionsLimit if filters["limit"] != "" { if n, err := strconv.Atoi(filters["limit"]); err == nil && n > 0 && n <= 500 { limit = n } } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() items, err := s.listDetections(ctx, filters["host"], filters["rule"], filters["severity"], filters["status"], limit) if err != nil { s.logger.Printf("ui detections: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } data := DetectionListPageData{ Title: "Detections", Now: time.Now(), Filters: filters, Detections: items, } s.renderTemplate(w, "detections", data) } func (s *server) handleUIEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } filter := EventFilter{ Host: strings.TrimSpace(r.URL.Query().Get("host")), Channel: strings.TrimSpace(r.URL.Query().Get("channel")), Rule: strings.TrimSpace(r.URL.Query().Get("rule")), User: strings.TrimSpace(r.URL.Query().Get("user")), SrcIP: strings.TrimSpace(r.URL.Query().Get("src_ip")), Severity: strings.TrimSpace(r.URL.Query().Get("severity")), TimeFrom: strings.TrimSpace(r.URL.Query().Get("from")), TimeTo: strings.TrimSpace(r.URL.Query().Get("to")), Limit: 100, } if v := strings.TrimSpace(r.URL.Query().Get("event_id")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { filter.EventID = uint32(n) } } if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 { filter.Limit = n } } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() events, err := s.listEvents(ctx, filter) if err != nil { s.logger.Printf("ui events: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } filters := map[string]string{ "host": filter.Host, "channel": filter.Channel, "rule": filter.Rule, "user": filter.User, "src_ip": filter.SrcIP, "severity": filter.Severity, "from": filter.TimeFrom, "to": filter.TimeTo, "limit": strconv.Itoa(filter.Limit), "event_id": func() string { if filter.EventID == 0 { return "" } return strconv.Itoa(int(filter.EventID)) }(), } data := EventListPageData{ Title: "Events", Now: time.Now(), Filters: filters, Events: events, } s.renderTemplate(w, "events", data) } func (s *server) handleUIEventDetail(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } idStr := strings.TrimSpace(r.URL.Query().Get("id")) if idStr == "" { writeError(w, http.StatusBadRequest, "missing id") return } id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() ev, err := s.getEventByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return } s.logger.Printf("ui event detail: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } data := EventDetailPageData{ Title: "Event Detail", Now: time.Now(), Event: ev, } s.renderTemplate(w, "event_detail", data) } func (s *server) renderTemplate(w http.ResponseWriter, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, name, data); err != nil { s.logger.Printf("render template %s: %v", name, err) http.Error(w, "template error", http.StatusInternalServerError) } } type EventFilter struct { Host string Channel string Rule string User string SrcIP string Severity string TimeFrom string TimeTo string EventID uint32 Limit int } func (s *server) getDashboardStats(ctx context.Context) (DashboardStats, error) { var stats DashboardStats if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agents WHERE is_enabled = 1`).Scan(&stats.AgentsTotal); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agents WHERE is_enabled = 1 AND last_seen >= ?`, time.Now().UTC().Add(-s.cfg.OfflineAfter)).Scan(&stats.AgentsActive); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM event_logs WHERE ts >= ?`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.Events24h); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM detections WHERE created_at >= ?`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.Detections24h); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM detections WHERE created_at >= ? AND severity = 'high'`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.HighDetections24h); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE status = 'open' `).Scan(&stats.OpenDetections); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE status = 'investigating' `).Scan(&stats.InvestigatingDetections); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE created_at >= ? AND severity = 'critical' `, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.CriticalDetections24h); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE created_at >= ? AND status = 'false_positive' `, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.FalsePositive24h); err != nil { return stats, err } if err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE created_at >= ? AND status = 'legitimate' `, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.Legitimate24h); err != nil { return stats, err } return stats, nil } func (s *server) listEvents(ctx context.Context, f EventFilter) ([]EventRow, error) { if f.Limit <= 0 || f.Limit > 1000 { f.Limit = 100 } query := ` SELECT id, hostname, channel_name, event_id, source, computer, provider_name, target_user, target_domain, subject_user, subject_domain, workstation, src_ip, src_port, logon_type, process_name, authentication_package, logon_process, status_text, sub_status_text, failure_reason, ts, received_at, msg FROM event_logs WHERE 1=1 ` args := make([]any, 0, 16) if f.Host != "" { query += ` AND hostname = ?` args = append(args, f.Host) } if f.Channel != "" { query += ` AND channel_name = ?` args = append(args, f.Channel) } if f.EventID != 0 { query += ` AND event_id = ?` args = append(args, f.EventID) } if f.User != "" { query += ` AND (target_user = ? OR subject_user = ?)` args = append(args, f.User, f.User) } if f.SrcIP != "" { query += ` AND src_ip = ?` args = append(args, f.SrcIP) } if f.TimeFrom != "" { if t, err := parseUIRFC3339(f.TimeFrom); err == nil { query += ` AND ts >= ?` args = append(args, t) } } if f.TimeTo != "" { if t, err := parseUIRFC3339(f.TimeTo); err == nil { query += ` AND ts <= ?` args = append(args, t) } } if f.Rule != "" || f.Severity != "" { query += ` AND EXISTS ( SELECT 1 FROM detections d WHERE d.hostname = event_logs.hostname AND event_logs.ts >= d.window_start AND event_logs.ts <= d.window_end ` if f.Rule != "" { query += ` AND d.rule_name = ?` args = append(args, f.Rule) } if f.Severity != "" { query += ` AND d.severity = ?` args = append(args, f.Severity) } query += ` )` } query += ` ORDER BY ts DESC LIMIT ?` args = append(args, f.Limit) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []EventRow for rows.Next() { var ev EventRow if err := rows.Scan( &ev.ID, &ev.Hostname, &ev.Channel, &ev.EventID, &ev.Source, &ev.Computer, &ev.ProviderName, &ev.TargetUser, &ev.TargetDomain, &ev.SubjectUser, &ev.SubjectDomain, &ev.Workstation, &ev.SrcIP, &ev.SrcPort, &ev.LogonType, &ev.ProcessName, &ev.AuthenticationPackage, &ev.LogonProcess, &ev.StatusText, &ev.SubStatusText, &ev.FailureReason, &ev.Time, &ev.ReceivedAt, &ev.Message, ); err != nil { return nil, err } out = append(out, ev) } return out, rows.Err() } func (s *server) getEventByID(ctx context.Context, id uint64) (EventRow, error) { const q = ` SELECT id, hostname, channel_name, event_id, source, computer, provider_name, target_user, target_domain, subject_user, subject_domain, workstation, src_ip, src_port, logon_type, process_name, authentication_package, logon_process, status_text, sub_status_text, failure_reason, ts, received_at, msg FROM event_logs WHERE id = ? LIMIT 1 ` var ev EventRow err := s.db.QueryRowContext(ctx, q, id).Scan( &ev.ID, &ev.Hostname, &ev.Channel, &ev.EventID, &ev.Source, &ev.Computer, &ev.ProviderName, &ev.TargetUser, &ev.TargetDomain, &ev.SubjectUser, &ev.SubjectDomain, &ev.Workstation, &ev.SrcIP, &ev.SrcPort, &ev.LogonType, &ev.ProcessName, &ev.AuthenticationPackage, &ev.LogonProcess, &ev.StatusText, &ev.SubStatusText, &ev.FailureReason, &ev.Time, &ev.ReceivedAt, &ev.Message, ) return ev, err } func parseUIRFC3339(v string) (time.Time, error) { return time.Parse(time.RFC3339, strings.TrimSpace(v)) } func loadConfig() Config { return Config{ ListenAddr: getenv("LISTEN_ADDR", ":8080"), DBDSN: mustGetenv("DB_DSN"), MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 10*1024*1024), HTTPReadTimeout: getenvDuration("HTTP_READ_TIMEOUT", 15*time.Second), HTTPWriteTimeout: getenvDuration("HTTP_WRITE_TIMEOUT", 30*time.Second), HTTPIdleTimeout: getenvDuration("HTTP_IDLE_TIMEOUT", 60*time.Second), DBMaxOpenConns: getenvInt("DB_MAX_OPEN_CONNS", 50), DBMaxIdleConns: getenvInt("DB_MAX_IDLE_CONNS", 25), DBConnMaxLifetime: getenvDuration("DB_CONN_MAX_LIFETIME", 3*time.Minute), DBConnMaxIdleTime: getenvDuration("DB_CONN_MAX_IDLE_TIME", 1*time.Minute), DetectionInterval: getenvDuration("DETECTION_INTERVAL", 1*time.Minute), OfflineAfter: getenvDuration("OFFLINE_AFTER", 10*time.Minute), OfflineAlertMax: getenvDuration("OFFLINE_ALERT_MAX", 120*time.Minute), FailedLogonWindow: getenvDuration("FAILED_LOGON_WINDOW", 5*time.Minute), FailedLogonThreshold: getenvInt("FAILED_LOGON_THRESHOLD", 25), RebootWindow: getenvDuration("REBOOT_WINDOW", 15*time.Minute), RebootThreshold: getenvInt("REBOOT_THRESHOLD", 3), PasswordSprayWindow: getenvDuration("PASSWORD_SPRAY_WINDOW", 5*time.Minute), PasswordSprayMinUsers: getenvInt("PASSWORD_SPRAY_MIN_USERS", 5), PasswordSprayMinAttempts: getenvInt("PASSWORD_SPRAY_MIN_ATTEMPTS", 15), SuccessAfterFailureWindow: getenvDuration("SUCCESS_AFTER_FAILURE_WINDOW", 10*time.Minute), NewSourceIPLookback: getenvDuration("NEW_SOURCE_IP_LOOKBACK", 30*24*time.Hour), NewSourceIPWindow: getenvDuration("NEW_SOURCE_IP_WINDOW", 10*time.Minute), DetectionsLimit: getenvInt("DETECTIONS_LIMIT", 100), EnrollmentKey: mustGetenv("ENROLLMENT_KEY"), BaselineEnabled: getenvBool("BASELINE_ENABLED", true), BaselineWindow: getenvDuration("BASELINE_WINDOW", 5*time.Minute), BaselineMinSamples: getenvInt("BASELINE_MIN_SAMPLES", 24), BaselineMinCount: getenvInt("BASELINE_MIN_COUNT", 10), BaselineMediumZScore: getenvFloat("BASELINE_MEDIUM_Z", 2.5), BaselineHighZScore: getenvFloat("BASELINE_HIGH_Z", 4.0), BaselineSuppressFor: getenvDuration("BASELINE_SUPPRESS_FOR", 1*time.Hour), Timezone: getenv("TZ", "Europe/Berlin"), UEBAEnabled: getenvBool("UEBA_ENABLED", true), UEBALookback: getenvDuration("UEBA_LOOKBACK", 30*24*time.Hour), UEBANewContextWindow: getenvDuration("UEBA_NEW_CONTEXT_WINDOW", 10*time.Minute), RiskScoreWindow: getenvDuration("RISK_SCORE_WINDOW", 24*time.Hour), } } func getenvBool(key string, def bool) bool { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } switch strings.ToLower(v) { case "1", "true", "yes", "y", "on": return true case "0", "false", "no", "n", "off": return false default: log.Fatalf("invalid bool for %s: %s", key, v) return def } } func getenvFloat(key string, def float64) float64 { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } f, err := strconv.ParseFloat(v, 64) if err != nil { log.Fatalf("invalid float for %s: %v", key, err) } return f } func (s *server) handleHealthz(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { ingestRejectedTotal.WithLabelValues("method_not_allowed").Inc() writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "uptime_sec": int(time.Since(s.startTime).Seconds()), }) } func (s *server) handleReadyz(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() if err := s.db.PingContext(ctx); err != nil { writeError(w, http.StatusServiceUnavailable, "database not ready") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } func (s *server) handleIngest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { ingestRejectedTotal.WithLabelValues("method_not_allowed").Inc() writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } apiKey := strings.TrimSpace(r.Header.Get("X-API-Key")) enrollmentKey := strings.TrimSpace(r.Header.Get("X-Enrollment-Key")) if apiKey == "" { ingestRejectedTotal.WithLabelValues("missing_api_key").Inc() writeError(w, http.StatusUnauthorized, "missing api key") return } r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes) defer r.Body.Close() dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() var batch []LogPayload if err := dec.Decode(&batch); err != nil { ingestRejectedTotal.WithLabelValues("invalid_json").Inc() writeError(w, http.StatusBadRequest, "invalid json") return } if len(batch) == 0 { ingestRejectedTotal.WithLabelValues("empty_batch").Inc() writeError(w, http.StatusBadRequest, "empty batch") return } if len(batch) > 1000 { ingestRejectedTotal.WithLabelValues("batch_too_large").Inc() writeError(w, http.StatusBadRequest, "batch too large") return } for i := range batch { if err := validatePayload(&batch[i]); err != nil { ingestRejectedTotal.WithLabelValues("invalid_payload").Inc() writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid payload at index %d: %v", i, err)) return } } hostname := batch[0].Hostname for i := 1; i < len(batch); i++ { if batch[i].Hostname != hostname { ingestRejectedTotal.WithLabelValues("mixed_hostnames").Inc() writeError(w, http.StatusBadRequest, "all events in a batch must use the same hostname") return } } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() agentID, err := s.authenticateTouchOrEnrollAgent( ctx, hostname, apiKey, enrollmentKey, clientIP(r.RemoteAddr), ) if err != nil { if errors.Is(err, errUnauthorized) { ingestRejectedTotal.WithLabelValues("unauthorized").Inc() writeError(w, http.StatusUnauthorized, "invalid api key or hostname") return } ingestRejectedTotal.WithLabelValues("auth_error").Inc() s.logger.Printf("authenticate agent: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } if err := s.insertBatch(ctx, agentID, batch); err != nil { dbInsertFailuresTotal.Inc() s.logger.Printf("insert batch: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } ingestBatchesTotal.Inc() for _, item := range batch { ingestEventsTotal.WithLabelValues(item.Channel, strconv.FormatUint(uint64(item.EventID), 10)).Inc() } writeJSON(w, http.StatusAccepted, ingestResponse{Accepted: len(batch)}) } func (s *server) handleDetections(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } limit := s.cfg.DetectionsLimit if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { limit = n } } host := strings.TrimSpace(r.URL.Query().Get("host")) rule := strings.TrimSpace(r.URL.Query().Get("rule")) severity := strings.TrimSpace(r.URL.Query().Get("severity")) status := strings.TrimSpace(r.URL.Query().Get("status")) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() items, err := s.listDetections(ctx, host, rule, severity, status, limit) if err != nil { s.logger.Printf("list detections: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, items) } func (s *server) authenticateTouchOrEnrollAgent(ctx context.Context, hostname, apiKey, enrollmentKey, remoteIP string) (uint64, error) { const q = ` SELECT id, api_key_hash, is_enabled FROM agents WHERE hostname = ? LIMIT 1 ` var id uint64 var storedHash string var enabled bool err := s.db.QueryRowContext(ctx, q, hostname).Scan(&id, &storedHash, &enabled) if err != nil { if errors.Is(err, sql.ErrNoRows) { if enrollmentKey == "" { return 0, errUnauthorized } if !constantTimeEqual(sha256Hex(enrollmentKey), sha256Hex(s.cfg.EnrollmentKey)) { return 0, errUnauthorized } return s.enrollNewAgent(ctx, hostname, apiKey, remoteIP) } return 0, err } if !enabled { return 0, errUnauthorized } if !constantTimeEqual(strings.ToLower(storedHash), sha256Hex(apiKey)) { return 0, errUnauthorized } const upd = ` UPDATE agents SET last_seen = UTC_TIMESTAMP(6), last_ip = ? WHERE id = ? ` if _, err := s.db.ExecContext(ctx, upd, remoteIP, id); err != nil { return 0, err } return id, nil } func (s *server) enrollNewAgent(ctx context.Context, hostname, apiKey, remoteIP string) (uint64, error) { if strings.TrimSpace(hostname) == "" { return 0, errUnauthorized } if strings.TrimSpace(apiKey) == "" { return 0, errUnauthorized } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return 0, err } defer func() { _ = tx.Rollback() }() const ins = ` INSERT INTO agents (hostname, api_key_hash, first_seen, last_seen, last_ip, is_enabled) VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?, 1) ` res, err := tx.ExecContext(ctx, ins, hostname, sha256Hex(apiKey), remoteIP) if err != nil { return 0, err } id, err := res.LastInsertId() if err != nil { return 0, err } if err := tx.Commit(); err != nil { return 0, err } s.logger.Printf("agent auto-enrolled: host=%s ip=%s", hostname, remoteIP) return uint64(id), nil } var errUnauthorized = errors.New("unauthorized") func (s *server) insertBatch(ctx context.Context, agentID uint64, batch []LogPayload) error { start := time.Now() tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() var sb strings.Builder args := make([]any, 0, len(batch)*28) sb.WriteString(` INSERT INTO event_logs ( agent_id, hostname, channel_name, event_id, source, computer, provider_name, level_value, task_value, opcode_value, keywords, target_user, target_domain, subject_user, subject_domain, workstation, src_ip, src_port, logon_type, process_name, authentication_package, logon_process, status_text, sub_status_text, failure_reason, ts, msg, msg_sha256 ) VALUES `) for i, item := range batch { if i > 0 { sb.WriteString(",") } sb.WriteString("(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)") norm := NormalizeEventXML(item.Message) realHost := firstNonEmpty(norm.Computer, item.Hostname) targetUser := normalizeUsername(norm.TargetUser) subjectUser := normalizeUsername(norm.SubjectUser) // Erfolgreicher Logon if item.Channel == "Security" && item.EventID == 4624 && targetUser != "" { priv, err := s.detector.isPrivilegedUser(ctx, targetUser) if err != nil { s.logger.Printf("privileged user check failed for %s: %v", targetUser, err) } else if priv { s.detector.privilegedLogonsTotal.WithLabelValues(targetUser, realHost).Inc() } } // Fehlgeschlagener Logon if item.Channel == "Security" && item.EventID == 4625 && targetUser != "" { priv, err := s.detector.isPrivilegedUser(ctx, targetUser) if err != nil { s.logger.Printf("privileged user check failed for %s: %v", targetUser, err) } else if priv { s.detector.privilegedLogonFailuresTotal.WithLabelValues(targetUser, realHost).Inc() } } // Special Privileged Logon if item.Channel == "Security" && item.EventID == 4672 && subjectUser != "" { priv, err := s.detector.isPrivilegedUser(ctx, subjectUser) if err != nil { s.logger.Printf("privileged user check failed for %s: %v", subjectUser, err) } else if priv { s.detector.privilegedLogonsTotal.WithLabelValues(subjectUser, realHost).Inc() } } args = append(args, agentID, realHost, item.Channel, item.EventID, item.Source, norm.Computer, firstNonEmpty(norm.ProviderName, item.Source), norm.LevelValue, norm.TaskValue, norm.OpcodeValue, norm.Keywords, norm.TargetUser, norm.TargetDomain, norm.SubjectUser, norm.SubjectDomain, norm.Workstation, norm.SrcIP, norm.SrcPort, norm.LogonType, norm.ProcessName, norm.AuthenticationPackage, norm.LogonProcess, norm.StatusText, norm.SubStatusText, norm.FailureReason, item.Time.UTC(), item.Message, sha256Hex(item.Message), ) } if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil { return err } if err := tx.Commit(); err != nil { return err } dbBatchSizeHist.Observe(float64(len(batch))) dbInsertEventsTotal.Add(float64(len(batch))) dbTxDurationHist.Observe(time.Since(start).Seconds()) return nil } func (s *server) listDetections(ctx context.Context, host, rule, severity, status string, limit int) ([]Detection, error) { base := ` SELECT id, rule_name, severity, hostname, channel_name, event_id, score, window_start, window_end, summary, details_json, created_at, status, COALESCE(analyst_note, ''), COALESCE(reviewed_by, ''), reviewed_at, is_false_positive, is_legitimate FROM detections WHERE 1=1 ` args := make([]any, 0, 4) if host != "" { base += " AND hostname = ?" args = append(args, host) } if rule != "" { base += " AND rule_name = ?" args = append(args, rule) } if severity != "" { base += " AND severity = ?" args = append(args, severity) } if status != "" { base += " AND status = ?" args = append(args, status) } base += " ORDER BY created_at DESC LIMIT ?" args = append(args, limit) rows, err := s.db.QueryContext(ctx, base, args...) if err != nil { return nil, err } defer rows.Close() var out []Detection for rows.Next() { var d Detection if err := rows.Scan( &d.ID, &d.RuleName, &d.Severity, &d.Hostname, &d.Channel, &d.EventID, &d.Score, &d.WindowStart, &d.WindowEnd, &d.Summary, &d.Details, &d.CreatedAt, &d.Status, &d.AnalystNote, &d.ReviewedBy, &d.ReviewedAt, &d.IsFalsePositive, &d.IsLegitimate, ); err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } func (s *server) runDetectionLoop() { ticker := time.NewTicker(s.cfg.DetectionInterval) defer ticker.Stop() s.runDetectionsOnce() for range ticker.C { s.runDetectionsOnce() } } func (d *detector) runBaselineUpdate(ctx context.Context) error { if !d.cfg.BaselineEnabled { return nil } windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.BaselineWindow) rows, err := d.db.QueryContext(ctx, ` SELECT hostname, channel_name, event_id, HOUR(ts) AS hour_of_day, WEEKDAY(ts) AS day_of_week, COUNT(*) AS cnt FROM event_logs WHERE ts >= ? AND ts < ? GROUP BY hostname, channel_name, event_id, HOUR(ts), WEEKDAY(ts) `, windowStart, windowEnd) if err != nil { return err } defer rows.Close() for rows.Next() { var b BaselineBucket if err := rows.Scan( &b.Hostname, &b.Channel, &b.EventID, &b.Hour, &b.DayOfWeek, &b.Count, ); err != nil { return err } excluded, err := d.isBaselineExcluded(ctx, b.Hostname, b.Channel, b.EventID) if err != nil { return err } if excluded { continue } incident, err := d.hasConfirmedIncidentInWindow(ctx, b.Hostname, b.Channel, b.EventID, windowStart, windowEnd) if err != nil { return err } if incident { continue } if err := d.updateBaselineBucket(ctx, b); err != nil { return err } } return rows.Err() } func (d *detector) hasConfirmedIncidentInWindow(ctx context.Context, hostname, channel string, eventID uint32, windowStart, windowEnd time.Time) (bool, error) { var count int err := d.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE status = 'confirmed_incident' AND hostname = ? AND channel_name = ? AND event_id = ? AND window_start < ? AND window_end > ? `, hostname, channel, eventID, windowEnd, windowStart, ).Scan(&count) if err != nil { return false, err } return count > 0, nil } func (d *detector) updateBaselineBucket(ctx context.Context, b BaselineBucket) error { tx, err := d.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() var stat BaselineStat err = tx.QueryRowContext(ctx, ` SELECT avg_count, m2_count, stddev_count, sample_count FROM baseline_event_stats WHERE hostname = ? AND channel_name = ? AND event_id = ? AND hour_of_day = ? AND day_of_week = ? FOR UPDATE `, b.Hostname, b.Channel, b.EventID, b.Hour, b.DayOfWeek, ).Scan( &stat.AvgCount, &stat.M2Count, &stat.StddevCount, &stat.SampleCount, ) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } x := float64(b.Count) if errors.Is(err, sql.ErrNoRows) { _, err := tx.ExecContext(ctx, ` INSERT INTO baseline_event_stats (hostname, channel_name, event_id, hour_of_day, day_of_week, avg_count, m2_count, stddev_count, sample_count) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 1) `, b.Hostname, b.Channel, b.EventID, b.Hour, b.DayOfWeek, x, ) if err != nil { return err } return tx.Commit() } newSamples := stat.SampleCount + 1 delta := x - stat.AvgCount newAvg := stat.AvgCount + delta/float64(newSamples) delta2 := x - newAvg newM2 := stat.M2Count + delta*delta2 newStddev := 0.0 if newSamples > 1 { newStddev = math.Sqrt(newM2 / float64(newSamples-1)) } _, err = tx.ExecContext(ctx, ` UPDATE baseline_event_stats SET avg_count = ?, m2_count = ?, stddev_count = ?, sample_count = ?, last_updated = UTC_TIMESTAMP(6) WHERE hostname = ? AND channel_name = ? AND event_id = ? AND hour_of_day = ? AND day_of_week = ? `, newAvg, newM2, newStddev, newSamples, b.Hostname, b.Channel, b.EventID, b.Hour, b.DayOfWeek, ) if err != nil { return err } return tx.Commit() } func (d *detector) runBaselineAnomalyRule(ctx context.Context) error { if !d.cfg.BaselineEnabled { return nil } windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.BaselineWindow) rows, err := d.db.QueryContext(ctx, ` SELECT e.hostname, e.channel_name, e.event_id, HOUR(e.ts) AS hour_of_day, WEEKDAY(e.ts) AS day_of_week, COUNT(*) AS cnt, b.avg_count, b.stddev_count, b.sample_count FROM event_logs e JOIN baseline_event_stats b ON b.hostname = e.hostname AND b.channel_name = e.channel_name AND b.event_id = e.event_id AND b.hour_of_day = HOUR(e.ts) AND b.day_of_week = WEEKDAY(e.ts) WHERE e.ts >= ? AND e.ts < ? GROUP BY e.hostname, e.channel_name, e.event_id, HOUR(e.ts), WEEKDAY(e.ts), b.avg_count, b.stddev_count, b.sample_count `, windowStart, windowEnd) if err != nil { return err } defer rows.Close() for rows.Next() { var host string var channel string var eventID uint32 var hour int var dayOfWeek int var count int var avg float64 var stddev float64 var samples int if err := rows.Scan( &host, &channel, &eventID, &hour, &dayOfWeek, &count, &avg, &stddev, &samples, ); err != nil { return err } eventIDStr := strconv.Itoa(int(eventID)) d.baselineCurrentCountGauge.WithLabelValues(host, channel, eventIDStr).Set(float64(count)) d.baselineAverageGauge.WithLabelValues(host, channel, eventIDStr).Set(avg) d.baselineStddevGauge.WithLabelValues(host, channel, eventIDStr).Set(stddev) d.baselineSamplesGauge.WithLabelValues(host, channel, eventIDStr).Set(float64(samples)) if samples < d.cfg.BaselineMinSamples { continue } if count < d.cfg.BaselineMinCount { continue } if stddev <= 0 { continue } z := (float64(count) - avg) / stddev if z < d.cfg.BaselineMediumZScore { continue } severity := "medium" if z >= d.cfg.BaselineHighZScore { severity = "high" } suppressed, err := d.isBaselineSuppressed(ctx, host, channel, eventID, windowEnd) if err != nil { return err } if suppressed { continue } score := z created, err := d.insertDetection(ctx, Detection{ RuleName: "baseline_event_rate_anomaly", Severity: severity, Hostname: host, Channel: channel, EventID: eventID, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf( "Baseline-Anomalie auf %s: %s EventID %d kam %d-mal in %d Minuten, normal %.2f ± %.2f, z=%.2f", host, channel, eventID, count, int(d.cfg.BaselineWindow.Minutes()), avg, stddev, z, ), Details: mustJSON(map[string]any{ "hostname": host, "channel": channel, "event_id": eventID, "count": count, "avg_count": avg, "stddev_count": stddev, "z_score": z, "sample_count": samples, "hour_of_day": hour, "day_of_week": dayOfWeek, "window_minutes": int(d.cfg.BaselineWindow.Minutes()), "min_samples": d.cfg.BaselineMinSamples, "medium_z": d.cfg.BaselineMediumZScore, "high_z": d.cfg.BaselineHighZScore, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("baseline_event_rate_anomaly", severity).Inc() d.anomalyScoreGauge.WithLabelValues(host, "baseline_event_rate_anomaly").Set(score) } } return rows.Err() } func (d *detector) isBaselineSuppressed(ctx context.Context, hostname, channel string, eventID uint32, now time.Time) (bool, error) { if d.cfg.BaselineSuppressFor <= 0 { return false, nil } since := now.UTC().Add(-d.cfg.BaselineSuppressFor) var count int err := d.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE rule_name = 'baseline_event_rate_anomaly' AND hostname = ? AND channel_name = ? AND event_id = ? AND created_at >= ? `, hostname, channel, eventID, since, ).Scan(&count) if err != nil { return false, err } return count > 0, nil } func (d *detector) runDynamicRules(ctx context.Context) error { rows, err := d.db.QueryContext(ctx, ` SELECT id, name, description, severity, channel, event_ids, match_field, match_operator, match_value, threshold_count, threshold_window_seconds, suppress_for_seconds FROM detection_rules WHERE enabled = 1 `) if err != nil { return err } defer rows.Close() for rows.Next() { var r DynamicRule if err := rows.Scan( &r.ID, &r.Name, &r.Description, &r.Severity, &r.Channel, &r.EventIDs, &r.MatchField, &r.MatchOperator, &r.MatchValue, &r.ThresholdCount, &r.ThresholdWindowSeconds, &r.SuppressForSeconds, ); err != nil { return err } if err := d.evaluateDynamicRule(ctx, r); err != nil { d.logger.Printf("dynamic rule %s error: %v", r.Name, err) d.ruleErrorsTotal.WithLabelValues("dynamic_" + r.Name).Inc() continue } d.ruleLastRunGauge.WithLabelValues("dynamic_" + r.Name).Set(float64(time.Now().Unix())) } return rows.Err() } func (d *detector) evaluateDynamicRule(ctx context.Context, r DynamicRule) error { eventIDs := parseCSVUint32(r.EventIDs) channels := parseCSVStrings(r.Channel) if len(eventIDs) == 0 || len(channels) == 0 { return nil } if r.ThresholdCount <= 1 || r.ThresholdWindowSeconds <= 0 { return d.evaluateSingleEventRule(ctx, r, channels, eventIDs) } return d.evaluateThresholdRule(ctx, r, channels, eventIDs) } func (d *detector) evaluateSingleEventRule(ctx context.Context, r DynamicRule, channels []string, eventIDs []uint32) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.DetectionInterval) query := ` SELECT id, hostname, channel_name, event_id, target_user, subject_user, src_ip, workstation, process_name, msg, ts FROM event_logs WHERE ts >= ? AND ts < ? ` args := []any{windowStart, windowEnd} query += buildInClause("channel_name", len(channels)) for _, ch := range channels { args = append(args, ch) } query += buildInClause("event_id", len(eventIDs)) for _, id := range eventIDs { args = append(args, id) } query += ` ORDER BY ts DESC LIMIT 500` rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return err } defer rows.Close() for rows.Next() { var ev struct { ID uint64 Hostname string Channel string EventID uint32 TargetUser string SubjectUser string SrcIP string Workstation string ProcessName string Message string Time time.Time } if err := rows.Scan( &ev.ID, &ev.Hostname, &ev.Channel, &ev.EventID, &ev.TargetUser, &ev.SubjectUser, &ev.SrcIP, &ev.Workstation, &ev.ProcessName, &ev.Message, &ev.Time, ); err != nil { return err } fields := map[string]string{ "hostname": ev.Hostname, "channel": ev.Channel, "event_id": strconv.Itoa(int(ev.EventID)), "target_user": ev.TargetUser, "subject_user": ev.SubjectUser, "src_ip": ev.SrcIP, "workstation": ev.Workstation, "process_name": ev.ProcessName, "msg": ev.Message, } if !dynamicRuleMatches(r, fields) { continue } if suppressed, err := d.isDynamicRuleSuppressed(ctx, r, ev.Hostname, ev.Time); err != nil { return err } else if suppressed { continue } summary := fmt.Sprintf("Dynamic Rule %s auf %s ausgelöst: EventID %d", r.Name, ev.Hostname, ev.EventID) created, err := d.insertDetection(ctx, Detection{ RuleName: "dynamic_" + r.Name, Severity: r.Severity, Hostname: ev.Hostname, Channel: ev.Channel, EventID: ev.EventID, Score: 1.0, WindowStart: ev.Time, WindowEnd: ev.Time, Summary: summary, Details: mustJSON(map[string]any{ "rule_id": r.ID, "rule_name": r.Name, "description": r.Description, "event_log_id": ev.ID, "target_user": ev.TargetUser, "subject_user": ev.SubjectUser, "src_ip": ev.SrcIP, "workstation": ev.Workstation, "process_name": ev.ProcessName, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("dynamic_"+r.Name, r.Severity).Inc() } } return rows.Err() } func (d *detector) evaluateThresholdRule(ctx context.Context, r DynamicRule, channels []string, eventIDs []uint32) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(time.Duration(-r.ThresholdWindowSeconds) * time.Second) query := ` SELECT hostname, COUNT(*) AS cnt, MIN(ts), MAX(ts) FROM event_logs WHERE ts >= ? AND ts < ? ` args := []any{windowStart, windowEnd} query += buildInClause("channel_name", len(channels)) for _, ch := range channels { args = append(args, ch) } query += buildInClause("event_id", len(eventIDs)) for _, id := range eventIDs { args = append(args, id) } if r.MatchField != "" && r.MatchOperator != "" && r.MatchValue != "" { sqlCond, sqlArgs := buildSQLMatchCondition(r) if sqlCond != "" { query += " AND " + sqlCond args = append(args, sqlArgs...) } } query += ` GROUP BY hostname HAVING COUNT(*) >= ? ` args = append(args, r.ThresholdCount) rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return err } defer rows.Close() for rows.Next() { var host string var count int var firstSeen time.Time var lastSeen time.Time if err := rows.Scan(&host, &count, &firstSeen, &lastSeen); err != nil { return err } if suppressed, err := d.isDynamicRuleSuppressed(ctx, r, host, windowEnd); err != nil { return err } else if suppressed { continue } score := float64(count) / float64(r.ThresholdCount) created, err := d.insertDetection(ctx, Detection{ RuleName: "dynamic_" + r.Name, Severity: r.Severity, Hostname: host, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf( "Dynamic Rule %s auf %s: %d Events in %d Sekunden", r.Name, host, count, r.ThresholdWindowSeconds, ), Details: mustJSON(map[string]any{ "rule_id": r.ID, "rule_name": r.Name, "description": r.Description, "count": count, "threshold_count": r.ThresholdCount, "threshold_window_sec": r.ThresholdWindowSeconds, "first_seen": firstSeen.UTC().Format(time.RFC3339Nano), "last_seen": lastSeen.UTC().Format(time.RFC3339Nano), "event_ids": r.EventIDs, "channels": r.Channel, "match_field": r.MatchField, "match_operator": r.MatchOperator, "match_value": r.MatchValue, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("dynamic_"+r.Name, r.Severity).Inc() d.anomalyScoreGauge.WithLabelValues(host, "dynamic_"+r.Name).Set(score) } } return rows.Err() } func (d *detector) isDynamicRuleSuppressed(ctx context.Context, r DynamicRule, hostname string, now time.Time) (bool, error) { if r.SuppressForSeconds <= 0 { return false, nil } since := now.UTC().Add(time.Duration(-r.SuppressForSeconds) * time.Second) var count int err := d.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detections WHERE rule_name = ? AND hostname = ? AND created_at >= ? `, "dynamic_"+r.Name, hostname, since).Scan(&count) if err != nil { return false, err } return count > 0, nil } func parseCSVStrings(v string) []string { parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func parseCSVUint32(v string) []uint32 { parts := strings.Split(v, ",") out := make([]uint32, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { continue } n, err := strconv.ParseUint(p, 10, 32) if err != nil { continue } out = append(out, uint32(n)) } return out } func buildInClause(field string, count int) string { if count <= 0 { return "" } var sb strings.Builder sb.WriteString(" AND ") sb.WriteString(field) sb.WriteString(" IN (") for i := 0; i < count; i++ { if i > 0 { sb.WriteString(",") } sb.WriteString("?") } sb.WriteString(")") return sb.String() } func dynamicRuleMatches(r DynamicRule, fields map[string]string) bool { if r.MatchField == "" || r.MatchOperator == "" || r.MatchValue == "" { return true } actual := strings.TrimSpace(fields[r.MatchField]) expected := strings.TrimSpace(r.MatchValue) switch strings.ToLower(r.MatchOperator) { case "eq": return strings.EqualFold(actual, expected) case "contains": return strings.Contains( strings.ToLower(actual), strings.ToLower(expected), ) case "in": values := parseCSVStrings(expected) for _, v := range values { if strings.EqualFold(actual, v) { return true } } return false default: return false } } func buildSQLMatchCondition(r DynamicRule) (string, []any) { fieldMap := map[string]string{ "hostname": "hostname", "channel": "channel_name", "event_id": "event_id", "target_user": "target_user", "subject_user": "subject_user", "src_ip": "src_ip", "workstation": "workstation", "process_name": "process_name", "msg": "msg", } col, ok := fieldMap[strings.ToLower(strings.TrimSpace(r.MatchField))] if !ok { return "", nil } op := strings.ToLower(strings.TrimSpace(r.MatchOperator)) val := strings.TrimSpace(r.MatchValue) switch op { case "eq": return col + " = ?", []any{val} case "contains": return col + " LIKE ?", []any{"%" + val + "%"} case "in": values := parseCSVStrings(val) if len(values) == 0 { return "", nil } var sb strings.Builder sb.WriteString(col) sb.WriteString(" IN (") args := make([]any, 0, len(values)) for i, v := range values { if i > 0 { sb.WriteString(",") } sb.WriteString("?") args = append(args, v) } sb.WriteString(")") return sb.String(), args } return "", nil } func (s *server) runDetectionsOnce() { ctx, cancel := context.WithTimeout(context.Background(), s.cfg.DetectionInterval) defer cancel() if err := s.detector.updateAgentMetrics(ctx); err != nil { s.logger.Printf("update agent metrics: %v", err) } rules := []struct { name string fn func(context.Context) error }{ {"agent_offline", s.detector.runAgentOfflineRule}, {"failed_logon_spike", s.detector.runFailedLogonSpikeRule}, {"reboot_spike", s.detector.runRebootSpikeRule}, {"new_event_id", s.detector.runNewEventIDRule}, {"password_spray", s.detector.runPasswordSprayRule}, {"success_after_failures", s.detector.runSuccessAfterFailuresRule}, {"new_source_ip_for_user", s.detector.runNewSourceIPForUserRule}, {"dynamic_rules", s.detector.runDynamicRules}, {"baseline_anomaly", s.detector.runBaselineAnomalyRule}, {"baseline_update", s.detector.runBaselineUpdate}, {"ueba_admin_new_host", s.detector.runAdminNewHostRule}, {"ueba_offhours_login", s.detector.runOffHoursLoginRule}, {"ueba_first_privileged_use", s.detector.runFirstTimePrivilegedRule}, {"ueba_new_user_context", s.detector.runUEBANewUserContextRule}, {"ueba_update", s.detector.runUEBABaselineUpdate}, {"host_risk_score", s.detector.runHostRiskScoreUpdate}, } for _, rule := range rules { start := time.Now() if err := rule.fn(ctx); err != nil { s.logger.Printf("rule %s error: %v", rule.name, err) s.detector.ruleErrorsTotal.WithLabelValues(rule.name).Inc() continue } s.detector.ruleLastRunGauge.WithLabelValues(rule.name).Set(float64(time.Now().Unix())) s.detector.ruleRuntimeHist.WithLabelValues(rule.name).Observe(time.Since(start).Seconds()) } } func (d *detector) runUEBABaselineUpdate(ctx context.Context) error { if !d.cfg.UEBAEnabled { return nil } windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.UEBANewContextWindow) rows, err := d.db.QueryContext(ctx, ` SELECT target_user, hostname, src_ip, workstation, COUNT(*) FROM event_logs WHERE channel_name = 'Security' AND event_id = 4624 AND ts >= ? AND ts < ? AND target_user <> '' AND target_user <> '-' AND target_user NOT LIKE '%$' GROUP BY target_user, hostname, src_ip, workstation `, windowStart, windowEnd) if err != nil { return err } defer rows.Close() for rows.Next() { var user, host, srcIP, workstation string var count int if err := rows.Scan(&user, &host, &srcIP, &workstation, &count); err != nil { return err } _, err := d.db.ExecContext(ctx, ` INSERT INTO ueba_user_baseline (username, hostname, src_ip, workstation, first_seen, last_seen, seen_count) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?) ON DUPLICATE KEY UPDATE last_seen = UTC_TIMESTAMP(6), seen_count = seen_count + VALUES(seen_count) `, user, host, srcIP, workstation, count, ) if err != nil { return err } } return rows.Err() } func (d *detector) runUEBANewUserContextRule(ctx context.Context) error { if !d.cfg.UEBAEnabled { return nil } windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.UEBANewContextWindow) lookbackStart := windowEnd.Add(-d.cfg.UEBALookback) rows, err := d.db.QueryContext(ctx, ` SELECT e.hostname, e.target_user, e.src_ip, e.workstation, COUNT(*) AS cnt FROM event_logs e WHERE e.channel_name = 'Security' AND e.event_id = 4624 AND e.ts >= ? AND e.ts < ? AND e.target_user <> '' AND e.target_user <> '-' AND e.target_user NOT LIKE '%$' AND NOT EXISTS ( SELECT 1 FROM ueba_user_baseline b WHERE b.username = e.target_user AND b.hostname = e.hostname AND b.src_ip = e.src_ip AND b.workstation = e.workstation AND b.first_seen < ? AND b.last_seen >= ? ) GROUP BY e.hostname, e.target_user, e.src_ip, e.workstation `, windowStart, windowEnd, windowStart, lookbackStart, ) if err != nil { return err } defer rows.Close() for rows.Next() { var host, user, srcIP, workstation string var count int if err := rows.Scan(&host, &user, &srcIP, &workstation, &count); err != nil { return err } score := 2.0 severity := "medium" if count >= 5 { score = 4.0 severity = "high" } created, err := d.insertDetection(ctx, Detection{ RuleName: "ueba_new_user_context", Severity: severity, Hostname: host, Channel: "Security", EventID: 4624, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf( "UEBA: Benutzer %s meldet sich in neuem Kontext an: Host=%s IP=%s Workstation=%s", user, host, srcIP, workstation, ), Details: mustJSON(map[string]any{ "user": user, "src_ip": srcIP, "workstation": workstation, "count": count, "lookback": d.cfg.UEBALookback.String(), }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("ueba_new_user_context", severity).Inc() d.anomalyScoreGauge.WithLabelValues(host, "ueba_new_user_context").Set(score) } } return rows.Err() } func riskWeight(severity string) float64 { switch severity { case "critical": return 25 case "high": return 10 case "medium": return 2 case "low": return 0.3 case "info": return 0.05 default: return 0.2 } } func severityFromRisk(score float64) string { switch { case score >= 120: return "critical" case score >= 60: return "high" case score >= 20: return "medium" case score >= 5: return "low" default: return "info" } } func (d *detector) runHostRiskScoreUpdate(ctx context.Context) error { windowStart := time.Now().UTC().Add(-d.cfg.RiskScoreWindow) if _, err := d.db.ExecContext(ctx, ` UPDATE host_risk_scores SET risk_score = 0, severity = 'info', open_detections = 0, high_detections = 0, critical_detections = 0, confirmed_incidents = 0, updated_at = UTC_TIMESTAMP(6) `); err != nil { return err } rows, err := d.db.QueryContext(ctx, ` SELECT hostname, severity, status, COUNT(*) AS cnt, MAX(created_at) AS last_detection_at FROM detections WHERE created_at >= ? AND status NOT IN ('false_positive', 'suppressed', 'legitimate', 'resolved', 'plausible') GROUP BY hostname, severity, status `, windowStart) if err != nil { return err } defer rows.Close() type agg struct { score float64 open int high int critical int confirmedIncidents int last time.Time } stats := map[string]*agg{} for rows.Next() { var host, sev, status string var count int var last time.Time if err := rows.Scan(&host, &sev, &status, &count, &last); err != nil { return err } a := stats[host] if a == nil { a = &agg{} stats[host] = a } w := riskWeight(sev) switch status { case "confirmed_incident": w += 75 a.confirmedIncidents += count case "investigating": w *= 2 a.open += count case "acknowledged": w *= 0.5 a.open += count case "open": w *= 0.35 a.open += count default: a.open += count } if sev == "high" { a.high += count } if sev == "critical" { a.critical += count } // Dämpfung: 100 gleiche offene Events sollen nicht 100x hart zählen. a.score += w * math.Sqrt(float64(count)) if last.After(a.last) { a.last = last } } if err := rows.Err(); err != nil { return err } for host, a := range stats { sev := severityFromRisk(a.score) _, err := d.db.ExecContext(ctx, ` INSERT INTO host_risk_scores (hostname, risk_score, severity, open_detections, high_detections, critical_detections, confirmed_incidents, last_detection_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE risk_score = VALUES(risk_score), severity = VALUES(severity), open_detections = VALUES(open_detections), high_detections = VALUES(high_detections), critical_detections = VALUES(critical_detections), confirmed_incidents = VALUES(confirmed_incidents), last_detection_at = VALUES(last_detection_at), updated_at = UTC_TIMESTAMP(6) `, host, a.score, sev, a.open, a.high, a.critical, a.confirmedIncidents, a.last, ) if err != nil { return err } if d.hostRiskScoreGauge != nil { d.hostRiskScoreGauge.WithLabelValues(host, sev).Set(a.score) } } return nil } func (d *detector) updateAgentMetrics(ctx context.Context) error { const q = ` SELECT hostname, last_seen FROM agents WHERE is_enabled = 1 ` rows, err := d.db.QueryContext(ctx, q) if err != nil { return err } defer rows.Close() active := 0 now := time.Now().UTC() for rows.Next() { var host string var lastSeen time.Time if err := rows.Scan(&host, &lastSeen); err != nil { return err } lastSeenUTC := lastSeen.UTC() d.lastSeenGauge.WithLabelValues(host).Set(float64(lastSeenUTC.Unix())) if now.Sub(lastSeenUTC) <= d.cfg.OfflineAfter { active++ } } if err := rows.Err(); err != nil { return err } d.activeAgentsGauge.Set(float64(active)) return nil } func (d *detector) runAgentOfflineRule(ctx context.Context) error { windowEnd := time.Now().UTC() offlineAfterTime := windowEnd.Add(-d.cfg.OfflineAfter) maxOfflineTime := windowEnd.Add(-d.cfg.OfflineAlertMax) const q = ` SELECT hostname, last_seen FROM agents WHERE is_enabled = 1 AND last_seen < ? AND last_seen >= ? ` rows, err := d.db.QueryContext(ctx, q, offlineAfterTime, maxOfflineTime) if err != nil { return err } defer rows.Close() for rows.Next() { var host string var lastSeen time.Time if err := rows.Scan(&host, &lastSeen); err != nil { return err } minutes := int(windowEnd.Sub(lastSeen.UTC()).Minutes()) score := math.Min(1.0, math.Max(0.1, float64(minutes)/float64(int(d.cfg.OfflineAfter.Minutes())))) severity := "low" d.anomalyScoreGauge.WithLabelValues(host, "agent_offline").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "agent_offline", Severity: severity, Hostname: host, Score: score, WindowStart: offlineAfterTime, WindowEnd: windowEnd, Summary: fmt.Sprintf("Agent %s ist seit %d Minuten offline", host, minutes), Details: mustJSON(map[string]any{ "last_seen": lastSeen.UTC().Format(time.RFC3339Nano), "offline_minutes": minutes, "offline_after_min": int(d.cfg.OfflineAfter.Minutes()), "offline_alert_max_min": int(d.cfg.OfflineAlertMax.Minutes()), }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("agent_offline", severity).Inc() } } return rows.Err() } func (d *detector) runFailedLogonSpikeRule(ctx context.Context) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.FailedLogonWindow) const q = ` SELECT hostname, COUNT(*) AS cnt FROM event_logs WHERE channel_name = 'Security' AND event_id = 4625 AND ts >= ? AND ts < ? GROUP BY hostname HAVING COUNT(*) >= ? ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, d.cfg.FailedLogonThreshold) if err != nil { return err } defer rows.Close() for rows.Next() { var host string var count int if err := rows.Scan(&host, &count); err != nil { return err } score := float64(count) / float64(d.cfg.FailedLogonThreshold) severity := severityFromScore(score, 1.0, 2.0, 4.0, 8.0) d.anomalyScoreGauge.WithLabelValues(host, "failed_logon_spike").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "failed_logon_spike", Severity: severity, Hostname: host, Channel: "Security", EventID: 4625, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf("Host %s hatte %d fehlgeschlagene Logons in %d Minuten", host, count, int(d.cfg.FailedLogonWindow.Minutes())), Details: mustJSON(map[string]any{ "count": count, "threshold": d.cfg.FailedLogonThreshold, "window_minutes": int(d.cfg.FailedLogonWindow.Minutes()), "event_id": 4625, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("failed_logon_spike", severity).Inc() } } return rows.Err() } func (d *detector) runRebootSpikeRule(ctx context.Context) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.RebootWindow) const q = ` SELECT hostname, COUNT(*) AS cnt FROM event_logs WHERE channel_name = 'System' AND event_id IN (1074, 6005, 6006) AND ts >= ? AND ts < ? GROUP BY hostname HAVING COUNT(*) >= ? ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, d.cfg.RebootThreshold) if err != nil { return err } defer rows.Close() for rows.Next() { var host string var count int if err := rows.Scan(&host, &count); err != nil { return err } score := float64(count) / float64(d.cfg.RebootThreshold) severity := severityFromScore(score, 1.0, 2.0, 4.0, 8.0) d.anomalyScoreGauge.WithLabelValues(host, "reboot_spike").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "reboot_spike", Severity: severity, Hostname: host, Channel: "System", Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf("Host %s hatte %d Reboot-/Shutdown-Events in %d Minuten", host, count, int(d.cfg.RebootWindow.Minutes())), Details: mustJSON(map[string]any{ "count": count, "threshold": d.cfg.RebootThreshold, "window_minutes": int(d.cfg.RebootWindow.Minutes()), "event_ids": []int{1074, 6005, 6006}, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("reboot_spike", severity).Inc() } } return rows.Err() } func (d *detector) runNewEventIDRule(ctx context.Context) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.DetectionInterval) const q = ` SELECT e.hostname, e.channel_name, e.event_id, COUNT(*) AS cnt FROM event_logs e WHERE e.ts >= ? AND e.ts < ? AND NOT EXISTS ( SELECT 1 FROM event_logs old WHERE old.hostname = e.hostname AND old.channel_name = e.channel_name AND old.event_id = e.event_id AND old.ts < ? ) GROUP BY e.hostname, e.channel_name, e.event_id ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, windowStart) if err != nil { return err } defer rows.Close() for rows.Next() { var host, channel string var eventID uint32 var count int if err := rows.Scan(&host, &channel, &eventID, &count); err != nil { return err } score := 1.0 + math.Log10(float64(count)+1) severity := "medium" if count >= 10 { severity = "high" } d.anomalyScoreGauge.WithLabelValues(host, "new_event_id").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "new_event_id", Severity: severity, Hostname: host, Channel: channel, EventID: eventID, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf("Host %s sendet erstmals Event-ID %d im Channel %s", host, eventID, channel), Details: mustJSON(map[string]any{ "count": count, "channel": channel, "event_id": eventID, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("new_event_id", severity).Inc() } } return rows.Err() } func (d *detector) runPasswordSprayRule(ctx context.Context) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.PasswordSprayWindow) const q = ` SELECT hostname, src_ip, COUNT(*) AS attempts, COUNT(DISTINCT target_user) AS users FROM event_logs WHERE channel_name = 'Security' AND event_id = 4625 AND ts >= ? AND ts < ? AND src_ip <> '' AND src_ip <> '-' AND src_ip <> '::1' AND src_ip <> '127.0.0.1' AND target_user <> '' AND target_user <> '-' GROUP BY hostname, src_ip HAVING COUNT(*) >= ? AND COUNT(DISTINCT target_user) >= ? ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, d.cfg.PasswordSprayMinAttempts, d.cfg.PasswordSprayMinUsers) if err != nil { return err } defer rows.Close() for rows.Next() { var host, srcIP string var attempts, users int if err := rows.Scan(&host, &srcIP, &attempts, &users); err != nil { return err } score := math.Max(float64(attempts)/float64(d.cfg.PasswordSprayMinAttempts), float64(users)/float64(d.cfg.PasswordSprayMinUsers)) severity := severityFromScore(score, 1.0, 2.0, 4.0, 8.0) d.anomalyScoreGauge.WithLabelValues(host, "password_spray").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "password_spray", Severity: severity, Hostname: host, Channel: "Security", EventID: 4625, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf("Möglicher Password-Spray auf %s von %s: %d Fehlversuche gegen %d Benutzer", host, srcIP, attempts, users), Details: mustJSON(map[string]any{ "src_ip": srcIP, "attempts": attempts, "distinct_users": users, "window_minutes": int(d.cfg.PasswordSprayWindow.Minutes()), "event_id": 4625, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("password_spray", severity).Inc() } } return rows.Err() } func (d *detector) runSuccessAfterFailuresRule(ctx context.Context) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.SuccessAfterFailureWindow) const q = ` SELECT s.hostname, s.target_user, s.src_ip, COUNT(*) AS success_count FROM event_logs s WHERE s.channel_name = 'Security' AND s.event_id = 4624 AND s.ts >= ? AND s.ts < ? AND s.target_user <> '' AND s.target_user <> '-' AND EXISTS ( SELECT 1 FROM event_logs f WHERE f.hostname = s.hostname AND f.channel_name = 'Security' AND f.event_id = 4625 AND f.target_user = s.target_user AND ( (f.src_ip = s.src_ip AND s.src_ip <> '' AND s.src_ip <> '-') OR (s.src_ip = '' OR s.src_ip = '-' OR f.src_ip = '' OR f.src_ip = '-') ) AND f.ts >= DATE_SUB(s.ts, INTERVAL ? SECOND) AND f.ts < s.ts ) GROUP BY s.hostname, s.target_user, s.src_ip ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, int(d.cfg.SuccessAfterFailureWindow.Seconds())) if err != nil { return err } defer rows.Close() for rows.Next() { var host, user, srcIP string var successCount int if err := rows.Scan(&host, &user, &srcIP, &successCount); err != nil { return err } score := 2.0 + math.Log10(float64(successCount)+1) severity := "high" d.anomalyScoreGauge.WithLabelValues(host, "success_after_failures").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "success_after_failures", Severity: severity, Hostname: host, Channel: "Security", EventID: 4624, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf("Erfolgreicher Logon nach Fehlversuchen auf %s für Benutzer %s", host, user), Details: mustJSON(map[string]any{ "user": user, "src_ip": srcIP, "success_count": successCount, "window_minutes": int(d.cfg.SuccessAfterFailureWindow.Minutes()), "success_event": 4624, "failure_event": 4625, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("success_after_failures", severity).Inc() } } return rows.Err() } func (d *detector) runNewSourceIPForUserRule(ctx context.Context) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.NewSourceIPWindow) lookbackStart := windowStart.Add(-d.cfg.NewSourceIPLookback) const q = ` SELECT e.hostname, e.target_user, e.src_ip, COUNT(*) AS cnt FROM event_logs e WHERE e.channel_name = 'Security' AND e.event_id = 4624 AND e.ts >= ? AND e.ts < ? AND e.target_user <> '' AND e.target_user <> '-' AND e.src_ip <> '' AND e.src_ip <> '-' AND e.src_ip <> '::1' AND e.src_ip <> '127.0.0.1' AND NOT EXISTS ( SELECT 1 FROM event_logs old WHERE old.hostname = e.hostname AND old.channel_name = 'Security' AND old.event_id = 4624 AND old.target_user = e.target_user AND old.src_ip = e.src_ip AND old.ts >= ? AND old.ts < ? ) GROUP BY e.hostname, e.target_user, e.src_ip ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, lookbackStart, windowStart) if err != nil { return err } defer rows.Close() for rows.Next() { var host, user, srcIP string var cnt int if err := rows.Scan(&host, &user, &srcIP, &cnt); err != nil { return err } score := 1.5 + math.Log10(float64(cnt)+1) severity := "medium" if cnt >= 5 { severity = "high" } d.anomalyScoreGauge.WithLabelValues(host, "new_source_ip_for_user").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "new_source_ip_for_user", Severity: severity, Hostname: host, Channel: "Security", EventID: 4624, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf("Benutzer %s meldet sich auf %s von neuer Quell-IP %s an", user, host, srcIP), Details: mustJSON(map[string]any{ "user": user, "src_ip": srcIP, "count": cnt, "window_minutes": int(d.cfg.NewSourceIPWindow.Minutes()), "lookback_hours": int(d.cfg.NewSourceIPLookback.Hours()), "event_id": 4624, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("new_source_ip_for_user", severity).Inc() } } return rows.Err() } func (d *detector) isDetectionSuppressed(ctx context.Context, det Detection) (bool, error) { var count int err := d.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM detection_suppressions WHERE enabled = 1 AND rule_name = ? AND (hostname = '' OR hostname = ?) AND (channel_name = '' OR channel_name = ?) AND (event_id = 0 OR event_id = ?) AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP(6)) `, det.RuleName, det.Hostname, det.Channel, det.EventID, ).Scan(&count) if err != nil { return false, err } return count > 0, nil } func (d *detector) insertDetection(ctx context.Context, det Detection) (bool, error) { suppressed, err := d.isDetectionSuppressed(ctx, det) if err != nil { return false, err } if suppressed { return false, nil } const q = ` INSERT IGNORE INTO detections (rule_name, severity, hostname, channel_name, event_id, score, window_start, window_end, summary, details_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` res, err := d.db.ExecContext(ctx, q, det.RuleName, det.Severity, det.Hostname, det.Channel, det.EventID, det.Score, det.WindowStart.UTC(), det.WindowEnd.UTC(), det.Summary, string(det.Details), ) if err != nil { return false, err } affected, err := res.RowsAffected() if err != nil { return false, err } return affected > 0, nil } func NormalizeEventXML(xmlStr string) NormalizedEvent { var out NormalizedEvent if strings.TrimSpace(xmlStr) == "" { return out } dec := xml.NewDecoder(strings.NewReader(xmlStr)) var path []string var currentDataName string for { tok, err := dec.Token() if err != nil { if err == io.EOF { break } return out } switch t := tok.(type) { case xml.StartElement: path = append(path, t.Name.Local) switch t.Name.Local { case "Provider": for _, a := range t.Attr { if a.Name.Local == "Name" { out.ProviderName = strings.TrimSpace(a.Value) } } case "Data": currentDataName = "" for _, a := range t.Attr { if a.Name.Local == "Name" { currentDataName = strings.TrimSpace(a.Value) break } } } case xml.EndElement: if len(path) > 0 { path = path[:len(path)-1] } if t.Name.Local == "Data" { currentDataName = "" } case xml.CharData: v := strings.TrimSpace(string(t)) if v == "" { continue } if endsWithPath(path, "System", "Computer") { out.Computer = v continue } if endsWithPath(path, "System", "Level") { out.LevelValue = parseUint32(v) continue } if endsWithPath(path, "System", "Task") { out.TaskValue = parseUint32(v) continue } if endsWithPath(path, "System", "Opcode") { out.OpcodeValue = parseUint32(v) continue } if endsWithPath(path, "System", "Keywords") { out.Keywords = v continue } if currentDataName != "" { switch currentDataName { case "TargetUserName": out.TargetUser = v case "TargetDomainName": out.TargetDomain = v case "SubjectUserName": out.SubjectUser = v case "SubjectDomainName": out.SubjectDomain = v case "WorkstationName": out.Workstation = v case "IpAddress": out.SrcIP = v case "IpPort": out.SrcPort = v case "LogonType": out.LogonType = v case "ProcessName": out.ProcessName = v case "AuthenticationPackageName": out.AuthenticationPackage = v case "LogonProcessName": out.LogonProcess = v case "Status": out.StatusText = v case "SubStatus": out.SubStatusText = v case "FailureReason": out.FailureReason = v case "MemberName": out.MemberName = v } } } } out.TargetUser = cleanupWinField(out.TargetUser) out.TargetDomain = cleanupWinField(out.TargetDomain) out.SubjectUser = cleanupWinField(out.SubjectUser) out.SubjectDomain = cleanupWinField(out.SubjectDomain) out.Workstation = cleanupWinField(out.Workstation) out.SrcIP = cleanupWinField(out.SrcIP) out.SrcPort = cleanupWinField(out.SrcPort) out.LogonType = cleanupWinField(out.LogonType) out.ProcessName = cleanupWinField(out.ProcessName) out.AuthenticationPackage = cleanupWinField(out.AuthenticationPackage) out.LogonProcess = cleanupWinField(out.LogonProcess) out.StatusText = cleanupWinField(out.StatusText) out.SubStatusText = cleanupWinField(out.SubStatusText) out.FailureReason = cleanupWinField(out.FailureReason) out.Computer = cleanupWinField(out.Computer) out.ProviderName = cleanupWinField(out.ProviderName) return out } func endsWithPath(path []string, parts ...string) bool { if len(path) < len(parts) { return false } start := len(path) - len(parts) for i := range parts { if path[start+i] != parts[i] { return false } } return true } func parseUint32(v string) uint32 { n, err := strconv.ParseUint(strings.TrimSpace(v), 10, 32) if err != nil { return 0 } return uint32(n) } func cleanupWinField(v string) string { v = strings.TrimSpace(v) if v == "" { return "" } if v == "-" { return "" } return v } func firstNonEmpty(v ...string) string { for _, s := range v { if strings.TrimSpace(s) != "" { return strings.TrimSpace(s) } } return "" } func validatePayload(p *LogPayload) error { p.Hostname = strings.TrimSpace(p.Hostname) p.Channel = strings.TrimSpace(p.Channel) p.Source = strings.TrimSpace(p.Source) if p.Hostname == "" { return errors.New("host is required") } if len(p.Hostname) > 255 { return errors.New("host too long") } if p.Channel == "" { return errors.New("channel is required") } if len(p.Channel) > 128 { return errors.New("channel too long") } if p.Source == "" { return errors.New("source is required") } if len(p.Source) > 255 { return errors.New("source too long") } if p.EventID == 0 { return errors.New("event id is required") } if p.Message == "" { return errors.New("msg is required") } if len(p.Message) > 2*1024*1024 { return errors.New("msg too large") } if p.Time.IsZero() { return errors.New("ts is required") } return nil } func severityFromScore(score, low, medium, high, critical float64) string { switch { case score >= critical: return "critical" case score >= high: return "high" case score >= medium: return "medium" case score >= low: return "low" default: return "info" } } func mustJSON(v any) json.RawMessage { b, _ := json.Marshal(v) return b } func metricsMiddleware(logger *log.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rw := &statusRecorder{ResponseWriter: w, status: 200} start := time.Now() next.ServeHTTP(rw, r) status := strconv.Itoa(rw.status) path := r.URL.Path httpRequestsTotal.WithLabelValues(path, r.Method, status).Inc() httpRequestDuration.WithLabelValues(path, r.Method, status).Observe(time.Since(start).Seconds()) logger.Printf("%s %s remote=%s status=%s dur=%s", r.Method, r.URL.Path, r.RemoteAddr, status, time.Since(start)) }) } type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(status int) { r.status = status r.ResponseWriter.WriteHeader(status) } func recoveryMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if recover() != nil { writeError(w, http.StatusInternalServerError, "internal error") } }() next.ServeHTTP(w, r) }) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func sha256Hex(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } func constantTimeEqual(a, b string) bool { if len(a) != len(b) { return false } var v byte for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] } return v == 0 } func clientIP(remoteAddr string) string { host, _, err := net.SplitHostPort(remoteAddr) if err != nil { return remoteAddr } return host } func getenv(key, def string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } return v } func mustGetenv(key string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { log.Fatalf("missing required environment variable: %s", key) } return v } func getenvInt(key string, def int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } n, err := strconv.Atoi(v) if err != nil { log.Fatalf("invalid int for %s: %v", key, err) } return n } func getenvInt64(key string, def int64) int64 { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } n, err := strconv.ParseInt(v, 10, 64) if err != nil { log.Fatalf("invalid int64 for %s: %v", key, err) } return n } func getenvDuration(key string, def time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return def } d, err := time.ParseDuration(v) if err != nil { log.Fatalf("invalid duration for %s: %v", key, err) } return d } func (d *detector) runOffHoursLoginRule(ctx context.Context) error { if !d.cfg.UEBAEnabled { return nil } nowLocal := time.Now().Local() hour := nowLocal.Hour() // Arbeitszeit: 6–20 Uhr if hour >= 6 && hour <= 20 { return nil } windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-5 * time.Minute) const q = ` SELECT hostname, target_user, COUNT(*) AS cnt FROM event_logs WHERE channel_name = 'Security' AND event_id = 4624 AND ts >= ? AND ts < ? AND target_user <> '' AND target_user <> '-' AND target_user NOT LIKE '%$' AND logon_type IN ('2', '7', '10', '11') AND LOWER(target_user) NOT IN ( 'system', 'localsystem', 'local service', 'network service', 'anonymous logon' ) GROUP BY hostname, target_user ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd) if err != nil { return err } defer rows.Close() for rows.Next() { var host, user string var count int if err := rows.Scan(&host, &user, &count); err != nil { return err } user = normalizeUsername(user) if isNoiseAccount(user) { continue } score := 2.0 severity := "medium" if count >= 5 { score = 4.0 severity = "high" } created, err := d.insertDetection(ctx, Detection{ RuleName: "ueba_offhours_login", Severity: severity, Hostname: host, Channel: "Security", EventID: 4624, Score: score, Summary: fmt.Sprintf("UEBA: Login außerhalb der Arbeitszeit: Benutzer %s auf Host %s", user, host), WindowStart: windowStart, WindowEnd: windowEnd, Details: mustJSON(map[string]any{ "user": user, "host": host, "local_hour": hour, "count": count, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("ueba_offhours_login", severity).Inc() d.anomalyScoreGauge.WithLabelValues(host, "ueba_offhours_login").Set(score) } } return rows.Err() } func (d *detector) runFirstTimePrivilegedRule(ctx context.Context) error { if !d.cfg.UEBAEnabled { return nil } windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-10 * time.Minute) const q = ` SELECT hostname, subject_user, COUNT(*) AS cnt FROM event_logs WHERE channel_name = 'Security' AND event_id = 4672 AND ts >= ? AND ts < ? AND subject_user <> '' AND subject_user <> '-' AND subject_user NOT LIKE '%$' GROUP BY hostname, subject_user ` rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd) if err != nil { return err } defer rows.Close() for rows.Next() { var host, user string var count int if err := rows.Scan(&host, &user, &count); err != nil { return err } user = normalizeUsername(user) if user == "" || isMachineAccount(user) { continue } var exists int err := d.db.QueryRowContext(ctx, ` SELECT 1 FROM user_privilege_baseline WHERE username = ? LIMIT 1 `, user).Scan(&exists) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } if errors.Is(err, sql.ErrNoRows) { created, err := d.insertDetection(ctx, Detection{ RuleName: "ueba_first_privileged_use", Severity: "high", Hostname: host, Channel: "Security", EventID: 4672, Score: 6.0, Summary: fmt.Sprintf("UEBA: Benutzer %s nutzt erstmals privilegierte Rechte auf Host %s", user, host), WindowStart: windowStart, WindowEnd: windowEnd, Details: mustJSON(map[string]any{ "user": user, "host": host, "count": count, }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("ueba_first_privileged_use", "high").Inc() d.anomalyScoreGauge.WithLabelValues(host, "ueba_first_privileged_use").Set(6.0) } _, err = d.db.ExecContext(ctx, ` INSERT INTO user_privilege_baseline (username, first_seen, last_seen, seen_count) VALUES (?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?) ON DUPLICATE KEY UPDATE last_seen = UTC_TIMESTAMP(6), seen_count = seen_count + VALUES(seen_count) `, user, count) if err != nil { return err } continue } _, err = d.db.ExecContext(ctx, ` UPDATE user_privilege_baseline SET last_seen = UTC_TIMESTAMP(6), seen_count = seen_count + ? WHERE username = ? `, count, user) if err != nil { return err } } return rows.Err() } func (d *detector) isPrivilegedUser(ctx context.Context, username string) (bool, error) { u := normalizeUsername(username) if isMachineAccount(u) { return false, nil } if strings.HasPrefix(u, "adm-") || strings.HasSuffix(u, "-adm") || strings.HasSuffix(u, ".adm") { return true, nil } var count int err := d.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM privileged_users WHERE enabled = 1 AND LOWER(username) = ? `, u).Scan(&count) if err != nil { return false, err } return count > 0, nil } func normalizeUsername(username string) string { u := strings.ToLower(strings.TrimSpace(username)) if strings.Contains(u, `\`) { parts := strings.Split(u, `\`) u = parts[len(parts)-1] } if strings.Contains(u, "@") { parts := strings.Split(u, "@") u = parts[0] } return u } func isMachineAccount(username string) bool { u := normalizeUsername(username) return u == "" || strings.HasSuffix(u, "$") } func (d *detector) runAdminNewHostRule(ctx context.Context) error { if !d.cfg.UEBAEnabled { return nil } windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.UEBANewContextWindow) lookbackStart := windowEnd.Add(-d.cfg.UEBALookback) rows, err := d.db.QueryContext(ctx, ` SELECT e.hostname, e.target_user, COUNT(*) AS cnt FROM event_logs e WHERE e.channel_name = 'Security' AND e.event_id = 4624 AND e.ts >= ? AND e.ts < ? AND e.target_user <> '' AND e.target_user <> '-' AND e.target_user NOT LIKE '%$' AND NOT EXISTS ( SELECT 1 FROM ueba_user_baseline b WHERE b.username = e.target_user AND b.hostname = e.hostname AND b.last_seen >= ? AND b.first_seen < ? ) GROUP BY e.hostname, e.target_user `, windowStart, windowEnd, lookbackStart, windowStart) if err != nil { return err } defer rows.Close() for rows.Next() { var host, user string var count int if err := rows.Scan(&host, &user, &count); err != nil { return err } privileged, err := d.isPrivilegedUser(ctx, user) if err != nil { return err } if !privileged { continue } score := 6.0 severity := "high" if count >= 3 { score = 9.0 severity = "critical" } created, err := d.insertDetection(ctx, Detection{ RuleName: "ueba_admin_new_host", Severity: severity, Hostname: host, Channel: "Security", EventID: 4624, Score: score, WindowStart: windowStart, WindowEnd: windowEnd, Summary: fmt.Sprintf("UEBA: Privilegierter Benutzer %s meldet sich erstmals auf Host %s an", user, host), Details: mustJSON(map[string]any{ "user": user, "host": host, "count": count, "lookback": d.cfg.UEBALookback.String(), "window": d.cfg.UEBANewContextWindow.String(), }), }) if err != nil { return err } if created { d.detectionHitsTotal.WithLabelValues("ueba_admin_new_host", severity).Inc() d.anomalyScoreGauge.WithLabelValues(host, "ueba_admin_new_host").Set(score) if d.privilegedNewHostTotal != nil { d.privilegedNewHostTotal.WithLabelValues(normalizeUsername(user), host).Inc() } } } return rows.Err() } func isNoiseAccount(username string) bool { u := normalizeUsername(username) if u == "" || isMachineAccount(u) { return true } switch u { case "system", "localsystem", "local service", "network service", "anonymous logon", "dwm-1", "dwm-2", "dwm-3", "umfd-0", "umfd-1", "umfd-2", "umfd-3": return true } return false }