From c371431ee084d05a41e18080e3af2fff1ff6eda7 Mon Sep 17 00:00:00 2001 From: jbergner Date: Sun, 26 Apr 2026 10:20:16 +0200 Subject: [PATCH] =?UTF-8?q?Batch-Update=20hinzugef=C3=BCgt,=20um=20Massen-?= =?UTF-8?q?Events=20bearbeiten=20zu=20k=C3=B6nnen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 184 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 90a12fd..0dd77a1 100644 --- a/main.go +++ b/main.go @@ -486,6 +486,7 @@ a { + @@ -503,9 +504,46 @@ a { Investigating False Positives Legitim + Plausibel Resolved Confirmed Incidents + +
+

Batch-Bewertung

+

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

+ +
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + + +

+ +

+
+
+
@@ -543,6 +581,7 @@ a { + @@ -573,6 +612,16 @@ a { + + + + + + + + + +
anzeigen @@ -1429,6 +1478,7 @@ func main() { 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) httpSrv := &http.Server{ Addr: cfg.ListenAddr, @@ -1462,6 +1512,136 @@ func main() { } } +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, @@ -1609,7 +1789,7 @@ func (s *server) handleUIDetectionUpdate(w http.ResponseWriter, r *http.Request) } switch status { - case "open", "acknowledged", "investigating", "legitimate", "false_positive", "resolved", "suppressed", "confirmed_incident": + case "open", "acknowledged", "investigating", "legitimate", "plausible", "false_positive", "resolved", "suppressed", "confirmed_incident": default: writeError(w, http.StatusBadRequest, "invalid status") return @@ -4188,7 +4368,7 @@ SELECT MAX(created_at) AS last_detection_at FROM detections WHERE created_at >= ? - AND status NOT IN ('false_positive', 'suppressed', 'legitimate', 'resolved') + AND status NOT IN ('false_positive', 'suppressed', 'legitimate', 'resolved', 'plausible') GROUP BY hostname, severity, status `, windowStart) if err != nil {