diff --git a/deploy/mariadb/init/001-schema.sql b/deploy/mariadb/init/001-schema.sql index 6d0a9d1..009afed 100644 --- a/deploy/mariadb/init/001-schema.sql +++ b/deploy/mariadb/init/001-schema.sql @@ -1800,4 +1800,14 @@ VALUES ('administrator', 'Built-in Administrator', 1), ('admin', 'Generic admin account', 1) ON DUPLICATE KEY UPDATE -reason = VALUES(reason); \ No newline at end of file +reason = VALUES(reason); + +CREATE TABLE IF NOT EXISTS user_source_ip_seen ( + username VARCHAR(255) NOT NULL, + src_ip VARCHAR(64) NOT NULL, + hostname VARCHAR(255) NOT NULL, + first_seen DATETIME(6) NOT NULL, + last_seen DATETIME(6) NOT NULL, + seen_count BIGINT NOT NULL DEFAULT 1, + PRIMARY KEY (username, src_ip, hostname) +); \ No newline at end of file diff --git a/main.go b/main.go index 182a8df..966b4cd 100644 --- a/main.go +++ b/main.go @@ -5278,29 +5278,31 @@ GROUP BY s.hostname, s.target_user, s.src_ip func (d *detector) runNewSourceIPForUserRule(ctx context.Context) error { windowEnd := time.Now().UTC() windowStart := windowEnd.Add(-d.cfg.NewSourceIPWindow) - lookbackStart := windowStart.Add(-d.cfg.NewSourceIPLookback) const q = ` -SELECT e.hostname, e.target_user, e.src_ip, COUNT(*) AS cnt +SELECT e.hostname, e.target_user, e.src_ip, MIN(e.ts) AS first_seen, COUNT(*) AS cnt FROM event_logs e WHERE e.channel_name = 'Security' AND e.event_id = 4624 AND e.ts >= ? AND e.ts < ? - AND e.target_user <> '' AND e.target_user <> '-' - AND e.src_ip <> '' AND e.src_ip <> '-' AND e.src_ip <> '::1' AND e.src_ip <> '127.0.0.1' - AND NOT EXISTS ( - SELECT 1 - FROM event_logs old - WHERE old.hostname = e.hostname - AND old.channel_name = 'Security' - AND old.event_id = 4624 - AND old.target_user = e.target_user - AND old.src_ip = e.src_ip - AND old.ts >= ? AND old.ts < ? + AND e.target_user <> '' + AND e.target_user <> '-' + AND e.target_user NOT LIKE '%$' + AND e.src_ip <> '' + AND e.src_ip <> '-' + AND e.src_ip <> '::1' + AND e.src_ip <> '127.0.0.1' + AND LOWER(e.target_user) NOT IN ( + 'system', + 'localsystem', + 'local service', + 'network service', + 'anonymous logon' ) GROUP BY e.hostname, e.target_user, e.src_ip ` - rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, lookbackStart, windowStart) + + rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd) if err != nil { return err } @@ -5308,17 +5310,33 @@ GROUP BY e.hostname, e.target_user, e.src_ip for rows.Next() { var host, user, srcIP string + var firstSeen time.Time var cnt int - if err := rows.Scan(&host, &user, &srcIP, &cnt); err != nil { + + if err := rows.Scan(&host, &user, &srcIP, &firstSeen, &cnt); err != nil { return err } + user = normalizeUsername(user) + if isNoiseAccount(user) { + continue + } + + isNew, err := d.touchUserSourceIPSeen(ctx, user, srcIP, host, firstSeen, cnt) + if err != nil { + return err + } + + if !isNew { + continue + } + score := 1.5 + math.Log10(float64(cnt)+1) + severity := "medium" if cnt >= 5 { severity = "high" } - d.anomalyScoreGauge.WithLabelValues(host, "new_source_ip_for_user").Set(score) created, err := d.insertDetection(ctx, Detection{ RuleName: "new_source_ip_for_user", @@ -5329,23 +5347,27 @@ GROUP BY e.hostname, e.target_user, e.src_ip Score: score, WindowStart: windowStart, WindowEnd: windowEnd, - Summary: fmt.Sprintf("Benutzer %s meldet sich auf %s von neuer Quell-IP %s an", user, host, srcIP), + Summary: fmt.Sprintf("Benutzer %s meldet sich auf %s erstmals von Quell-IP %s an", user, host, srcIP), Details: mustJSON(map[string]any{ "user": user, "src_ip": srcIP, + "host": host, "count": cnt, + "first_seen": firstSeen.UTC().Format(time.RFC3339Nano), "window_minutes": int(d.cfg.NewSourceIPWindow.Minutes()), - "lookback_hours": int(d.cfg.NewSourceIPLookback.Hours()), "event_id": 4624, }), }) if err != nil { return err } + if created { d.detectionHitsTotal.WithLabelValues("new_source_ip_for_user", severity).Inc() + d.anomalyScoreGauge.WithLabelValues(host, "new_source_ip_for_user").Set(score) } } + return rows.Err() } @@ -5859,6 +5881,46 @@ GROUP BY hostname, target_user return rows.Err() } +func (d *detector) touchUserSourceIPSeen(ctx context.Context, username, srcIP, hostname string, firstSeen time.Time, count int) (bool, error) { + username = normalizeUsername(username) + hostname = strings.TrimSpace(hostname) + srcIP = strings.TrimSpace(srcIP) + + if username == "" || srcIP == "" || hostname == "" { + return false, nil + } + + res, err := d.db.ExecContext(ctx, ` +INSERT INTO user_source_ip_seen +(username, src_ip, hostname, first_seen, last_seen, seen_count) +VALUES (?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE +last_seen = VALUES(last_seen), +seen_count = seen_count + VALUES(seen_count) +`, + username, + srcIP, + hostname, + firstSeen.UTC(), + firstSeen.UTC(), + count, + ) + if err != nil { + return false, err + } + + affected, err := res.RowsAffected() + if err != nil { + return false, err + } + + // MySQL: + // 1 = Insert + // 2 = Update durch ON DUPLICATE KEY UPDATE + // 0 = kein effektives Update, je nach Client/Settings möglich + return affected == 1, nil +} + func (d *detector) runFirstTimePrivilegedRule(ctx context.Context) error { if !d.cfg.UEBAEnabled { return nil