Batch-Update hinzugefügt, um Massen-Events bearbeiten zu können.
All checks were successful
release-tag / release-image (push) Successful in 2m10s

This commit is contained in:
2026-04-26 10:20:16 +02:00
parent c5bc52788b
commit c371431ee0

184
main.go
View File

@@ -486,6 +486,7 @@ a {
<option value="acknowledged" {{if eq (index .Filters "status") "acknowledged"}}selected{{end}}>acknowledged</option>
<option value="investigating" {{if eq (index .Filters "status") "investigating"}}selected{{end}}>investigating</option>
<option value="legitimate" {{if eq (index .Filters "status") "legitimate"}}selected{{end}}>legitim</option>
<option value="plausible" {{if eq .Status "plausible"}}selected{{end}}>plausibel</option>
<option value="false_positive" {{if eq (index .Filters "status") "false_positive"}}selected{{end}}>false positive</option>
<option value="resolved" {{if eq (index .Filters "status") "resolved"}}selected{{end}}>resolved</option>
<option value="suppressed" {{if eq (index .Filters "status") "suppressed"}}selected{{end}}>suppressed</option>
@@ -503,9 +504,46 @@ a {
<a href="/ui/detections?status=investigating">Investigating</a>
<a href="/ui/detections?status=false_positive">False Positives</a>
<a href="/ui/detections?status=legitimate">Legitim</a>
<a href="/ui/detections?status=plausible">Plausibel</a>
<a href="/ui/detections?status=resolved">Resolved</a>
<a href="/ui/detections?status=confirmed_incident">Confirmed Incidents</a>
</div>
<div class="card">
<h2>Batch-Bewertung</h2>
<p class="muted">Bearbeitet mehrere Detections anhand von Rule, Host, Channel, EventID oder Zeitfenster.</p>
<form method="post" action="/ui/detections/batch-update">
<div class="filters">
<div><label>Rule</label><input name="rule" value="{{index .Filters "rule"}}"></div>
<div><label>Host</label><input name="host" value="{{index .Filters "host"}}"></div>
<div><label>Channel</label><input name="channel"></div>
<div><label>Event ID</label><input name="event_id"></div>
<div><label>From RFC3339</label><input name="from" placeholder="2026-04-26T08:00:00+02:00"></div>
<div><label>To RFC3339</label><input name="to" placeholder="2026-04-26T12:00:00+02:00"></div>
<div><label>Limit</label><input name="limit" value="5000"></div>
<div>
<label>Status</label>
<select name="status">
<option value="plausible">plausibel</option>
<option value="legitimate">legitim</option>
<option value="false_positive">false positive</option>
<option value="resolved">resolved</option>
<option value="suppressed">suppressed</option>
<option value="acknowledged">acknowledged</option>
</select>
</div>
</div>
<label>Notiz</label>
<textarea name="note" rows="3" placeholder="z. B. Patchday, Software-Rollout, Wartungsfenster"></textarea>
<p>
<button type="submit">Batch anwenden</button>
</p>
</form>
</div>
<div class="table-wrap">
<table>
<tr>
@@ -543,6 +581,7 @@ a {
<option value="acknowledged" {{if eq .Status "acknowledged"}}selected{{end}}>ack</option>
<option value="investigating" {{if eq .Status "investigating"}}selected{{end}}>investigating</option>
<option value="legitimate" {{if eq .Status "legitimate"}}selected{{end}}>legitim</option>
<option value="plausible" {{if eq .Status "plausible"}}selected{{end}}>plausibel</option>
<option value="false_positive" {{if eq .Status "false_positive"}}selected{{end}}>false positive</option>
<option value="resolved" {{if eq .Status "resolved"}}selected{{end}}>resolved</option>
<option value="suppressed" {{if eq .Status "suppressed"}}selected{{end}}>suppressed</option>
@@ -573,6 +612,16 @@ a {
<input name="note" placeholder="Notiz" value="{{.AnalystNote}}">
<button type="submit">Speichern</button>
</form>
<form method="post" action="/ui/detections/batch-update" class="mini-form">
<input type="hidden" name="rule" value="{{.RuleName}}">
<input type="hidden" name="host" value="{{.Hostname}}">
<input type="hidden" name="channel" value="{{.Channel}}">
<input type="hidden" name="event_id" value="{{.EventID}}">
<input type="hidden" name="status" value="plausible">
<input type="hidden" name="note" value="Gleicher Typ wurde gesammelt als plausibel markiert">
<input type="hidden" name="limit" value="5000">
<button type="submit">Ähnliche plausibel</button>
</form>
</td>
<td>
<a href="/ui/events?host={{q .Hostname}}&rule={{q .RuleName}}&severity={{q .Severity}}">anzeigen</a>
@@ -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 {