This commit is contained in:
File diff suppressed because it is too large
Load Diff
760
main.go
760
main.go
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user