Anpassungen für echte Incidents, damit diese während der Lernphase nicht in das dauerhafte Trainings-Set aufgenommen werden.
All checks were successful
release-tag / release-image (push) Successful in 2m29s

This commit is contained in:
2026-04-25 21:32:19 +02:00
parent ac3ecccd0b
commit c563367978
2 changed files with 176 additions and 2 deletions

View File

@@ -1380,4 +1380,19 @@ CREATE TABLE detection_suppressions (
);
CREATE INDEX idx_suppressions_lookup
ON detection_suppressions (enabled, rule_name, hostname, channel_name, event_id);
ON detection_suppressions (enabled, rule_name, hostname, channel_name, event_id);
CREATE TABLE baseline_exclusions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
hostname VARCHAR(255) NOT NULL DEFAULT '',
channel_name VARCHAR(255) NOT NULL DEFAULT '',
event_id INT NOT NULL DEFAULT 0,
reason TEXT NULL,
created_by VARCHAR(128) NOT NULL DEFAULT '',
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
expires_at TIMESTAMP(6) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1
);
CREATE INDEX idx_baseline_exclusions_lookup
ON baseline_exclusions (enabled, hostname, channel_name, event_id, expires_at);

161
main.go
View File

@@ -488,6 +488,7 @@ a {
<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>
<option value="confirmed_incident" {{if eq (index .Filters "status") "confirmed_incident"}}selected{{end}}>confirmed incident</option>
</select>
</div>
<div><label>Limit</label><input name="limit" value="{{index .Filters "limit"}}"></div>
@@ -502,6 +503,7 @@ a {
<a href="/ui/detections?status=false_positive">False Positives</a>
<a href="/ui/detections?status=legitimate">Legitim</a>
<a href="/ui/detections?status=resolved">Resolved</a>
<a href="/ui/detections?status=confirmed_incident">Confirmed Incidents</a>
</div>
<div class="table-wrap">
<table>
@@ -543,6 +545,7 @@ a {
<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>
<option value="confirmed_incident" {{if eq (index .Filters "status") "confirmed_incident"}}selected{{end}}>confirmed incident</option>
</select>
<label class="checkline">
@@ -557,6 +560,15 @@ a {
<option value="0">dauerhaft</option>
</select>
<label>Baseline</label>
<select name="baseline_action">
<option value="">keine Änderung</option>
<option value="exclude_24h">nicht lernen: 24h</option>
<option value="exclude_7d">nicht lernen: 7 Tage</option>
<option value="exclude_30d">nicht lernen: 30 Tage</option>
<option value="exclude_forever">nicht lernen: dauerhaft</option>
</select>
<input name="note" placeholder="Notiz" value="{{.AnalystNote}}">
<button type="submit">Speichern</button>
</form>
@@ -1334,6 +1346,28 @@ func main() {
}
}
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")
@@ -1360,7 +1394,7 @@ func (s *server) handleUIDetectionUpdate(w http.ResponseWriter, r *http.Request)
}
switch status {
case "open", "acknowledged", "investigating", "legitimate", "false_positive", "resolved", "suppressed":
case "open", "acknowledged", "investigating", "legitimate", "false_positive", "resolved", "suppressed", "confirmed_incident":
default:
writeError(w, http.StatusBadRequest, "invalid status")
return
@@ -1419,6 +1453,64 @@ WHERE id = ?
}
}
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"
@@ -1427,6 +1519,30 @@ WHERE id = ?
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,
@@ -2828,6 +2944,22 @@ GROUP BY hostname, channel_name, event_id, HOUR(ts), WEEKDAY(ts)
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
}
@@ -2836,6 +2968,33 @@ GROUP BY hostname, channel_name, event_id, HOUR(ts), WEEKDAY(ts)
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 {