Privilegierte Benutzer-Accounts hinzufügen
All checks were successful
release-tag / release-image (push) Successful in 2m14s

This commit is contained in:
2026-04-26 20:59:21 +02:00
parent 6353abaab7
commit 3b1ca05fa5
2 changed files with 633 additions and 3 deletions

View File

@@ -1768,4 +1768,36 @@ VALUES
UPDATE detection_rules
SET enabled = 0
WHERE name LIKE 'v1_%';
WHERE name LIKE 'v1_%';
CREATE TABLE user_host_baseline (
username VARCHAR(128),
hostname VARCHAR(128),
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (username, hostname)
);
CREATE TABLE IF NOT EXISTS user_privilege_baseline (
username VARCHAR(255) NOT NULL PRIMARY KEY,
first_seen DATETIME(6) NOT NULL DEFAULT UTC_TIMESTAMP(6),
last_seen DATETIME(6) NOT NULL DEFAULT UTC_TIMESTAMP(6),
seen_count BIGINT NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS privileged_users (
username VARCHAR(255) PRIMARY KEY,
reason TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
);
CREATE INDEX idx_privileged_users_enabled
ON privileged_users (enabled);
INSERT INTO privileged_users (username, reason, enabled)
VALUES
('administrator', 'Built-in Administrator', 1),
('admin', 'Generic admin account', 1)
ON DUPLICATE KEY UPDATE
reason = VALUES(reason);

602
main.go
View File

@@ -515,6 +515,7 @@ a:hover {
<nav>
<a href="/ui">Overview</a>
<a href="/ui/soc">SOC</a>
<a href="/ui/privileged-users">Privileged Users</a>
<a href="/ui/agents">Agents</a>
<a href="/ui/rules">Rules</a>
<a href="/ui/baseline">Baseline</a>
@@ -532,6 +533,72 @@ a:hover {
</html>
{{end}}
{{define "privileged_users"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<p class="muted">Privilegierte Benutzer für UEBA-Regeln wie „Admin auf neuem Host“.</p>
<div class="card">
<h2>Benutzer hinzufügen</h2>
<form method="post" action="/ui/privileged-users/save">
<div class="filters">
<div>
<label>Username</label>
<input name="username" placeholder="adm.mustermann oder administrator" required>
</div>
<div>
<label>Grund</label>
<input name="reason" placeholder="Domain Admin, Server Admin, Helpdesk Admin">
</div>
</div>
<button type="submit">Speichern</button>
</form>
</div>
<h2>Privileged Users</h2>
<div class="table-wrap">
<table>
<tr>
<th>Username</th>
<th>Grund</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Aktion</th>
</tr>
{{range .Users}}
<tr>
<td><span class="mono">{{.Username}}</span></td>
<td>{{.Reason}}</td>
<td>
{{if .Enabled}}
<span class="badge badge-on">aktiv</span>
{{else}}
<span class="badge badge-muted">inaktiv</span>
{{end}}
</td>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{fmtTime .UpdatedAt}}</td>
<td>
<form method="post" action="/ui/privileged-users/toggle" class="inline-form">
<input type="hidden" name="username" value="{{.Username}}">
{{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>
</div>
{{template "footer" .}}
{{end}}
{{define "dashboard"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
@@ -1189,6 +1256,10 @@ type detector struct {
baselineSamplesGauge *prometheus.GaugeVec
hostRiskScoreGauge *prometheus.GaugeVec
privilegedLogonsTotal *prometheus.CounterVec
privilegedLogonFailuresTotal *prometheus.CounterVec
privilegedNewHostTotal *prometheus.CounterVec
}
type EventRow struct {
@@ -1389,6 +1460,20 @@ type SOCPageData struct {
RecentIncidents []SOCRecentIncidentRow
}
type PrivilegedUserRow struct {
Username string
Reason string
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
type PrivilegedUsersPageData struct {
Title string
Now time.Time
Users []PrivilegedUserRow
}
var (
httpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_http_requests_total", Help: "Total HTTP requests."},
@@ -1526,6 +1611,27 @@ func main() {
},
[]string{"host", "severity"},
),
privilegedLogonsTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "siem_privileged_logons_total",
Help: "Successful logons by privileged users.",
},
[]string{"user", "host"},
),
privilegedLogonFailuresTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "siem_privileged_logon_failures_total",
Help: "Failed logons by privileged users.",
},
[]string{"user", "host"},
),
privilegedNewHostTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "siem_privileged_new_host_total",
Help: "Privileged user logged on to a new host.",
},
[]string{"user", "host"},
),
}
reg.MustRegister(
d.lastSeenGauge,
@@ -1540,6 +1646,9 @@ func main() {
d.baselineStddevGauge,
d.baselineSamplesGauge,
d.hostRiskScoreGauge,
d.privilegedLogonsTotal,
d.privilegedLogonFailuresTotal,
d.privilegedNewHostTotal,
)
s := &server{
@@ -1594,6 +1703,9 @@ func main() {
mux.HandleFunc("/ui/baseline", s.handleUIBaseline)
mux.HandleFunc("/ui/soc", s.handleUISOC)
mux.HandleFunc("/ui/detections/batch-update", s.handleUIDetectionsBatchUpdate)
mux.HandleFunc("/ui/privileged-users", s.handleUIPrivilegedUsers)
mux.HandleFunc("/ui/privileged-users/save", s.handleUIPrivilegedUserSave)
mux.HandleFunc("/ui/privileged-users/toggle", s.handleUIPrivilegedUserToggle)
httpSrv := &http.Server{
Addr: cfg.ListenAddr,
@@ -1627,6 +1739,128 @@ func main() {
}
}
func (s *server) listPrivilegedUsers(ctx context.Context) ([]PrivilegedUserRow, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT username, COALESCE(reason, ''), enabled, created_at, updated_at
FROM privileged_users
ORDER BY username ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PrivilegedUserRow
for rows.Next() {
var u PrivilegedUserRow
if err := rows.Scan(&u.Username, &u.Reason, &u.Enabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *server) handleUIPrivilegedUsers(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()
users, err := s.listPrivilegedUsers(ctx)
if err != nil {
s.logger.Printf("privileged users: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
s.renderTemplate(w, "privileged_users", PrivilegedUsersPageData{
Title: "Privileged Users",
Now: time.Now().In(s.location),
Users: users,
})
}
func (s *server) handleUIPrivilegedUserSave(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
}
username := normalizeUsername(r.FormValue("username"))
reason := strings.TrimSpace(r.FormValue("reason"))
if username == "" || strings.HasSuffix(username, "$") {
writeError(w, http.StatusBadRequest, "invalid username")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err := s.db.ExecContext(ctx, `
INSERT INTO privileged_users (username, reason, enabled)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE
reason = VALUES(reason),
enabled = 1,
updated_at = UTC_TIMESTAMP(6)
`, username, reason)
if err != nil {
s.logger.Printf("save privileged user: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
http.Redirect(w, r, "/ui/privileged-users", http.StatusSeeOther)
}
func (s *server) handleUIPrivilegedUserToggle(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
}
username := normalizeUsername(r.FormValue("username"))
enabled := strings.TrimSpace(r.FormValue("enabled")) == "1"
if username == "" {
writeError(w, http.StatusBadRequest, "invalid username")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err := s.db.ExecContext(ctx, `
UPDATE privileged_users
SET enabled = ?,
updated_at = UTC_TIMESTAMP(6)
WHERE username = ?
`, enabled, username)
if err != nil {
s.logger.Printf("toggle privileged user: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
http.Redirect(w, r, "/ui/privileged-users", http.StatusSeeOther)
}
func (s *server) handleUIDetectionsBatchUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -2386,7 +2620,6 @@ func (s *server) handleUIRuleSave(w http.ResponseWriter, r *http.Request) {
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 == "" {
@@ -3296,9 +3529,43 @@ INSERT INTO event_logs (
norm := NormalizeEventXML(item.Message)
realHost := firstNonEmpty(norm.Computer, item.Hostname)
targetUser := normalizeUsername(norm.TargetUser)
subjectUser := normalizeUsername(norm.SubjectUser)
// Erfolgreicher Logon
if item.Channel == "Security" && item.EventID == 4624 && targetUser != "" {
priv, err := s.detector.isPrivilegedUser(ctx, targetUser)
if err != nil {
s.logger.Printf("privileged user check failed for %s: %v", targetUser, err)
} else if priv {
s.detector.privilegedLogonsTotal.WithLabelValues(targetUser, realHost).Inc()
}
}
// Fehlgeschlagener Logon
if item.Channel == "Security" && item.EventID == 4625 && targetUser != "" {
priv, err := s.detector.isPrivilegedUser(ctx, targetUser)
if err != nil {
s.logger.Printf("privileged user check failed for %s: %v", targetUser, err)
} else if priv {
s.detector.privilegedLogonFailuresTotal.WithLabelValues(targetUser, realHost).Inc()
}
}
// Special Privileged Logon
if item.Channel == "Security" && item.EventID == 4672 && subjectUser != "" {
priv, err := s.detector.isPrivilegedUser(ctx, subjectUser)
if err != nil {
s.logger.Printf("privileged user check failed for %s: %v", subjectUser, err)
} else if priv {
s.detector.privilegedLogonsTotal.WithLabelValues(subjectUser, realHost).Inc()
}
}
args = append(args,
agentID,
item.Hostname,
realHost,
item.Channel,
item.EventID,
item.Source,
@@ -4262,6 +4529,9 @@ func (s *server) runDetectionsOnce() {
{"baseline_anomaly", s.detector.runBaselineAnomalyRule},
{"baseline_update", s.detector.runBaselineUpdate},
{"ueba_admin_new_host", s.detector.runAdminNewHostRule},
{"ueba_offhours_login", s.detector.runOffHoursLoginRule},
{"ueba_first_privileged_use", s.detector.runFirstTimePrivilegedRule},
{"ueba_new_user_context", s.detector.runUEBANewUserContextRule},
{"ueba_update", s.detector.runUEBABaselineUpdate},
{"host_risk_score", s.detector.runHostRiskScoreUpdate},
@@ -5495,3 +5765,331 @@ func getenvDuration(key string, def time.Duration) time.Duration {
}
return d
}
func (d *detector) runOffHoursLoginRule(ctx context.Context) error {
if !d.cfg.UEBAEnabled {
return nil
}
nowLocal := time.Now().Local()
hour := nowLocal.Hour()
// Arbeitszeit: 620 Uhr
if hour >= 6 && hour <= 20 {
return nil
}
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-5 * time.Minute)
const q = `
SELECT hostname, target_user, COUNT(*) AS cnt
FROM event_logs
WHERE channel_name = 'Security'
AND event_id = 4624
AND ts >= ? AND ts < ?
AND target_user <> ''
AND target_user <> '-'
AND target_user NOT LIKE '%$'
GROUP BY hostname, target_user
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, user string
var count int
if err := rows.Scan(&host, &user, &count); err != nil {
return err
}
user = normalizeUsername(user)
if user == "" || isMachineAccount(user) {
continue
}
score := 2.0
severity := "medium"
if count >= 5 {
score = 4.0
severity = "high"
}
created, err := d.insertDetection(ctx, Detection{
RuleName: "ueba_offhours_login",
Severity: severity,
Hostname: host,
Channel: "Security",
EventID: 4624,
Score: score,
Summary: fmt.Sprintf("UEBA: Login außerhalb der Arbeitszeit: Benutzer %s auf Host %s", user, host),
WindowStart: windowStart,
WindowEnd: windowEnd,
Details: mustJSON(map[string]any{
"user": user,
"host": host,
"local_hour": hour,
"count": count,
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("ueba_offhours_login", severity).Inc()
d.anomalyScoreGauge.WithLabelValues(host, "ueba_offhours_login").Set(score)
}
}
return rows.Err()
}
func (d *detector) runFirstTimePrivilegedRule(ctx context.Context) error {
if !d.cfg.UEBAEnabled {
return nil
}
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-10 * time.Minute)
const q = `
SELECT hostname, subject_user, COUNT(*) AS cnt
FROM event_logs
WHERE channel_name = 'Security'
AND event_id = 4672
AND ts >= ? AND ts < ?
AND subject_user <> ''
AND subject_user <> '-'
AND subject_user NOT LIKE '%$'
GROUP BY hostname, subject_user
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, user string
var count int
if err := rows.Scan(&host, &user, &count); err != nil {
return err
}
user = normalizeUsername(user)
if user == "" || isMachineAccount(user) {
continue
}
var exists int
err := d.db.QueryRowContext(ctx, `
SELECT 1
FROM user_privilege_baseline
WHERE username = ?
LIMIT 1
`, user).Scan(&exists)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if errors.Is(err, sql.ErrNoRows) {
created, err := d.insertDetection(ctx, Detection{
RuleName: "ueba_first_privileged_use",
Severity: "high",
Hostname: host,
Channel: "Security",
EventID: 4672,
Score: 6.0,
Summary: fmt.Sprintf("UEBA: Benutzer %s nutzt erstmals privilegierte Rechte auf Host %s", user, host),
WindowStart: windowStart,
WindowEnd: windowEnd,
Details: mustJSON(map[string]any{
"user": user,
"host": host,
"count": count,
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("ueba_first_privileged_use", "high").Inc()
d.anomalyScoreGauge.WithLabelValues(host, "ueba_first_privileged_use").Set(6.0)
}
_, err = d.db.ExecContext(ctx, `
INSERT INTO user_privilege_baseline
(username, first_seen, last_seen, seen_count)
VALUES (?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?)
ON DUPLICATE KEY UPDATE
last_seen = UTC_TIMESTAMP(6),
seen_count = seen_count + VALUES(seen_count)
`, user, count)
if err != nil {
return err
}
continue
}
_, err = d.db.ExecContext(ctx, `
UPDATE user_privilege_baseline
SET last_seen = UTC_TIMESTAMP(6),
seen_count = seen_count + ?
WHERE username = ?
`, count, user)
if err != nil {
return err
}
}
return rows.Err()
}
func (d *detector) isPrivilegedUser(ctx context.Context, username string) (bool, error) {
u := normalizeUsername(username)
if isMachineAccount(u) {
return false, nil
}
if strings.HasPrefix(u, "adm-") || strings.HasSuffix(u, "-adm") || strings.HasSuffix(u, ".adm") {
return true, nil
}
var count int
err := d.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM privileged_users
WHERE enabled = 1
AND LOWER(username) = ?
`, u).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func normalizeUsername(username string) string {
u := strings.ToLower(strings.TrimSpace(username))
if strings.Contains(u, `\`) {
parts := strings.Split(u, `\`)
u = parts[len(parts)-1]
}
if strings.Contains(u, "@") {
parts := strings.Split(u, "@")
u = parts[0]
}
return u
}
func isMachineAccount(username string) bool {
u := normalizeUsername(username)
return u == "" || strings.HasSuffix(u, "$")
}
func (d *detector) runAdminNewHostRule(ctx context.Context) error {
if !d.cfg.UEBAEnabled {
return nil
}
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.UEBANewContextWindow)
lookbackStart := windowEnd.Add(-d.cfg.UEBALookback)
rows, err := d.db.QueryContext(ctx, `
SELECT e.hostname, e.target_user, 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.target_user NOT LIKE '%$'
AND NOT EXISTS (
SELECT 1
FROM ueba_user_baseline b
WHERE b.username = e.target_user
AND b.hostname = e.hostname
AND b.last_seen >= ?
AND b.first_seen < ?
)
GROUP BY e.hostname, e.target_user
`, windowStart, windowEnd, lookbackStart, windowStart)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, user string
var count int
if err := rows.Scan(&host, &user, &count); err != nil {
return err
}
privileged, err := d.isPrivilegedUser(ctx, user)
if err != nil {
return err
}
if !privileged {
continue
}
score := 6.0
severity := "high"
if count >= 3 {
score = 9.0
severity = "critical"
}
created, err := d.insertDetection(ctx, Detection{
RuleName: "ueba_admin_new_host",
Severity: severity,
Hostname: host,
Channel: "Security",
EventID: 4624,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("UEBA: Privilegierter Benutzer %s meldet sich erstmals auf Host %s an", user, host),
Details: mustJSON(map[string]any{
"user": user,
"host": host,
"count": count,
"lookback": d.cfg.UEBALookback.String(),
"window": d.cfg.UEBANewContextWindow.String(),
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("ueba_admin_new_host", severity).Inc()
d.anomalyScoreGauge.WithLabelValues(host, "ueba_admin_new_host").Set(score)
if d.privilegedNewHostTotal != nil {
d.privilegedNewHostTotal.WithLabelValues(normalizeUsername(user), host).Inc()
}
}
}
return rows.Err()
}