New Mega Update
All checks were successful
release-tag / release-image (push) Successful in 2m22s

This commit is contained in:
2026-04-24 20:28:54 +02:00
parent eaef7322be
commit 90eeb98882
2 changed files with 1964 additions and 3 deletions

File diff suppressed because it is too large Load Diff

760
main.go
View File

@@ -63,6 +63,19 @@ const uiTemplates = `
.inline-form { display: inline; }
button.danger { background: #dc2626; }
button.success { background: #16a34a; }
textarea, select {
width: 100%;
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 8px;
box-sizing: border-box;
}
.badge { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: bold; }
.badge-on { background: #dcfce7; color: #166534; }
.badge-off { background: #fee2e2; color: #991b1b; }
.inline-form { display: inline; }
button.danger { background: #dc2626; }
button.success { background: #16a34a; }
</style>
</head>
<body>
@@ -71,6 +84,7 @@ const uiTemplates = `
<nav>
<a href="/ui">Dashboard</a>
<a href="/ui/agents">Agents</a>
<a href="/ui/rules">Rules</a>
<a href="/ui/detections">Detections</a>
<a href="/ui/events">Events</a>
<a href="/metrics">Metrics</a>
@@ -158,6 +172,80 @@ const uiTemplates = `
{{template "footer" .}}
{{end}}
{{define "rules"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<p class="muted">Dynamische Regeln für einfache EventID-, Feld- und Threshold-Erkennung.</p>
<h2>Neue Regel</h2>
<form method="post" action="/ui/rules/save">
<div class="filters">
<div><label>Name</label><input name="name" required></div>
<div><label>Severity</label>
<select name="severity">
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high</option>
</select>
</div>
<div><label>Channel</label><input name="channel" value="Security"></div>
<div><label>Event IDs</label><input name="event_ids" placeholder="4720,4722,1102" required></div>
<div><label>Match Field</label><input name="match_field" placeholder="target_user, subject_user, msg, src_ip"></div>
<div><label>Operator</label>
<select name="match_operator">
<option value="">kein Filter</option>
<option value="eq">eq</option>
<option value="contains">contains</option>
<option value="in">in</option>
</select>
</div>
<div><label>Match Value</label><input name="match_value"></div>
<div><label>Threshold Count</label><input name="threshold_count" value="1"></div>
<div><label>Window Seconds</label><input name="threshold_window_seconds" value="0"></div>
<div><label>Suppress Seconds</label><input name="suppress_for_seconds" value="3600"></div>
</div>
<div><label>Beschreibung</label><textarea name="description" rows="3"></textarea></div>
<p><button type="submit">Regel speichern</button></p>
</form>
<h2>Bestehende Regeln</h2>
<table>
<tr>
<th>Name</th><th>Severity</th><th>Channel</th><th>Events</th><th>Filter</th><th>Threshold</th><th>Status</th><th>Aktion</th>
</tr>
{{range .Rules}}
<tr>
<td><strong>{{.Name}}</strong><br><span class="muted">{{.Description}}</span></td>
<td class="sev-{{.Severity}}">{{.Severity}}</td>
<td>{{.Channel}}</td>
<td>{{.EventIDs}}</td>
<td>{{.MatchField}} {{.MatchOperator}} {{.MatchValue}}</td>
<td>{{.ThresholdCount}} / {{.ThresholdWindowSeconds}}s</td>
<td>
{{if .Enabled}}
<span class="badge badge-on">aktiv</span>
{{else}}
<span class="badge badge-off">inaktiv</span>
{{end}}
</td>
<td>
<form class="inline-form" method="post" action="/ui/rules/toggle">
<input type="hidden" name="id" value="{{.ID}}">
{{if .Enabled}}
<input type="hidden" name="enabled" value="0">
<button class="danger" type="submit">Deaktivieren</button>
{{else}}
<input type="hidden" name="enabled" value="1">
<button class="success" type="submit">Aktivieren</button>
{{end}}
</form>
</td>
</tr>
{{end}}
</table>
{{template "footer" .}}
{{end}}
{{define "events"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
@@ -335,6 +423,8 @@ type NormalizedEvent struct {
StatusText string
SubStatusText string
FailureReason string
MemberName string
GroupName string
}
type Detection struct {
@@ -460,6 +550,30 @@ type AgentListPageData struct {
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
}
var (
httpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_http_requests_total", Help: "Total HTTP requests."},
@@ -609,6 +723,9 @@ func main() {
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)
httpSrv := &http.Server{
Addr: cfg.ListenAddr,
@@ -642,6 +759,201 @@ func main() {
}
}
func (s *server) listDynamicRules(ctx context.Context) ([]DynamicRule, error) {
const q = `
SELECT id, name, description, severity, channel, event_ids,
match_field, match_operator, 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),
Enabled: true,
}
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
@@ -1503,6 +1815,447 @@ func (s *server) runDetectionLoop() {
}
}
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()
@@ -1522,6 +2275,7 @@ func (s *server) runDetectionsOnce() {
{"password_spray", s.detector.runPasswordSprayRule},
{"success_after_failures", s.detector.runSuccessAfterFailuresRule},
{"new_source_ip_for_user", s.detector.runNewSourceIPForUserRule},
{"dynamic_rules", s.detector.runDynamicRules},
}
for _, rule := range rules {
@@ -1599,8 +2353,8 @@ WHERE is_enabled = 1
minutes := int(windowEnd.Sub(lastSeen.UTC()).Minutes())
score := math.Max(1, float64(minutes)/float64(int(d.cfg.OfflineAfter.Minutes())))
severity := severityFromScore(score, 1.5, 3.0)
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)
@@ -2150,6 +2904,8 @@ func NormalizeEventXML(xmlStr string) NormalizedEvent {
out.SubStatusText = v
case "FailureReason":
out.FailureReason = v
case "MemberName":
out.MemberName = v
}
}
}