SOC-Update
All checks were successful
release-tag / release-image (push) Successful in 2m58s

This commit is contained in:
2026-04-26 09:59:34 +02:00
parent 416d2bd41f
commit c4e586c0bf
2 changed files with 533 additions and 1 deletions

View File

@@ -1395,4 +1395,34 @@ CREATE TABLE baseline_exclusions (
);
CREATE INDEX idx_baseline_exclusions_lookup
ON baseline_exclusions (enabled, hostname, channel_name, event_id, expires_at);
ON baseline_exclusions (enabled, hostname, channel_name, event_id, expires_at);
CREATE TABLE host_risk_scores (
hostname VARCHAR(255) PRIMARY KEY,
risk_score DOUBLE NOT NULL DEFAULT 0,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
open_detections INT NOT NULL DEFAULT 0,
high_detections INT NOT NULL DEFAULT 0,
critical_detections INT NOT NULL DEFAULT 0,
confirmed_incidents INT NOT NULL DEFAULT 0,
last_detection_at TIMESTAMP(6) NULL,
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
);
CREATE TABLE ueba_user_baseline (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL,
hostname VARCHAR(255) NOT NULL,
src_ip VARCHAR(255) NOT NULL DEFAULT '',
workstation VARCHAR(255) NOT NULL DEFAULT '',
first_seen TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
last_seen TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
seen_count BIGINT NOT NULL DEFAULT 1,
UNIQUE KEY uniq_user_context (username, hostname, src_ip, workstation)
);
CREATE INDEX idx_ueba_user_baseline_user
ON ueba_user_baseline (username, last_seen);
CREATE INDEX idx_host_risk_score
ON host_risk_scores (risk_score, severity);

502
main.go
View File

@@ -399,6 +399,7 @@ a {
<div><strong>SIEM-lite</strong></div>
<nav>
<a href="/ui">Dashboard</a>
<a href="/ui/soc">SOC</a>
<a href="/ui/agents">Agents</a>
<a href="/ui/rules">Rules</a>
<a href="/ui/baseline">Baseline</a>
@@ -659,6 +660,66 @@ a {
{{template "footer" .}}
{{end}}
{{define "soc"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<p class="muted">Stand: {{fmtTime .Now}}</p>
<h2>Top Host Risk Scores</h2>
<div class="table-wrap">
<table>
<tr>
<th>Host</th>
<th>Risk</th>
<th>Severity</th>
<th>Open</th>
<th>High</th>
<th>Critical</th>
<th>Confirmed</th>
<th>Last Detection</th>
</tr>
{{range .TopHosts}}
<tr>
<td><strong><a href="/ui/detections?host={{q .Hostname}}">{{.Hostname}}</a></strong></td>
<td><strong>{{printf "%.1f" .RiskScore}}</strong></td>
<td><span class="badge sev-{{.Severity}}">{{.Severity}}</span></td>
<td>{{.OpenDetections}}</td>
<td>{{.HighDetections}}</td>
<td>{{.CriticalDetections}}</td>
<td>{{.ConfirmedIncidents}}</td>
<td>{{if .LastDetectionAt.Valid}}{{fmtTime .LastDetectionAt.Time}}{{end}}</td>
</tr>
{{end}}
</table>
</div>
<h2>Recent SOC Relevant Detections</h2>
<div class="table-wrap">
<table>
<tr>
<th>Zeit</th>
<th>Rule</th>
<th>Severity</th>
<th>Status</th>
<th>Host</th>
<th>Summary</th>
</tr>
{{range .RecentIncidents}}
<tr>
<td>{{fmtTime .CreatedAt}}</td>
<td><span class="mono">{{.RuleName}}</span></td>
<td><span class="badge sev-{{.Severity}}">{{.Severity}}</span></td>
<td><span class="badge status-{{.Status}}">{{.Status}}</span></td>
<td><a href="/ui/detections?host={{q .Hostname}}">{{.Hostname}}</a></td>
<td class="wrap">{{.Summary}}</td>
</tr>
{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}
{{define "baseline"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
@@ -866,6 +927,11 @@ type Config struct {
BaselineSuppressFor time.Duration
Timezone string
UEBAEnabled bool
UEBALookback time.Duration
UEBANewContextWindow time.Duration
RiskScoreWindow time.Duration
}
type LogPayload struct {
@@ -957,6 +1023,8 @@ type detector struct {
baselineAverageGauge *prometheus.GaugeVec
baselineStddevGauge *prometheus.GaugeVec
baselineSamplesGauge *prometheus.GaugeVec
hostRiskScoreGauge *prometheus.GaugeVec
}
type EventRow struct {
@@ -1128,6 +1196,35 @@ type baselineDetailsJSON struct {
WindowMinutes int `json:"window_minutes"`
}
type SOCHostRiskRow struct {
Hostname string
RiskScore float64
Severity string
OpenDetections int
HighDetections int
CriticalDetections int
ConfirmedIncidents int
LastDetectionAt sql.NullTime
UpdatedAt time.Time
}
type SOCRecentIncidentRow struct {
ID uint64
CreatedAt time.Time
RuleName string
Severity string
Status string
Hostname string
Summary string
}
type SOCPageData struct {
Title string
Now time.Time
TopHosts []SOCHostRiskRow
RecentIncidents []SOCRecentIncidentRow
}
var (
httpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_http_requests_total", Help: "Total HTTP requests."},
@@ -1258,6 +1355,13 @@ func main() {
},
[]string{"host", "channel", "event_id"},
),
hostRiskScoreGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "eventcollector_host_risk_score",
Help: "Calculated risk score per host.",
},
[]string{"host", "severity"},
),
}
reg.MustRegister(
d.lastSeenGauge,
@@ -1271,6 +1375,7 @@ func main() {
d.baselineAverageGauge,
d.baselineStddevGauge,
d.baselineSamplesGauge,
d.hostRiskScoreGauge,
)
s := &server{
@@ -1323,6 +1428,7 @@ func main() {
mux.HandleFunc("/ui/rules/save", s.handleUIRuleSave)
mux.HandleFunc("/ui/rules/toggle", s.handleUIRuleToggle)
mux.HandleFunc("/ui/baseline", s.handleUIBaseline)
mux.HandleFunc("/ui/soc", s.handleUISOC)
httpSrv := &http.Server{
Addr: cfg.ListenAddr,
@@ -1356,6 +1462,105 @@ func main() {
}
}
func (s *server) listSOCTopHosts(ctx context.Context, limit int) ([]SOCHostRiskRow, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT hostname, risk_score, severity, open_detections,
high_detections, critical_detections, confirmed_incidents,
last_detection_at, updated_at
FROM host_risk_scores
ORDER BY risk_score DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SOCHostRiskRow
for rows.Next() {
var r SOCHostRiskRow
if err := rows.Scan(
&r.Hostname,
&r.RiskScore,
&r.Severity,
&r.OpenDetections,
&r.HighDetections,
&r.CriticalDetections,
&r.ConfirmedIncidents,
&r.LastDetectionAt,
&r.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) listSOCRecentIncidents(ctx context.Context, limit int) ([]SOCRecentIncidentRow, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_at, rule_name, severity, status, hostname, summary
FROM detections
WHERE status IN ('open', 'investigating', 'confirmed_incident')
OR severity IN ('high', 'critical')
ORDER BY created_at DESC
LIMIT ?
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SOCRecentIncidentRow
for rows.Next() {
var r SOCRecentIncidentRow
if err := rows.Scan(
&r.ID,
&r.CreatedAt,
&r.RuleName,
&r.Severity,
&r.Status,
&r.Hostname,
&r.Summary,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *server) handleUISOC(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()
topHosts, err := s.listSOCTopHosts(ctx, 20)
if err != nil {
s.logger.Printf("soc top hosts: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
incidents, err := s.listSOCRecentIncidents(ctx, 50)
if err != nil {
s.logger.Printf("soc recent incidents: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
s.renderTemplate(w, "soc", SOCPageData{
Title: "SOC Dashboard",
Now: time.Now(),
TopHosts: topHosts,
RecentIncidents: incidents,
})
}
func (s *server) createBaselineExclusionFromDetection(ctx context.Context, det Detection, reason, createdBy string, hours int) error {
var expiresAt any = nil
if hours > 0 {
@@ -2493,6 +2698,11 @@ func loadConfig() Config {
BaselineSuppressFor: getenvDuration("BASELINE_SUPPRESS_FOR", 1*time.Hour),
Timezone: getenv("TZ", "Europe/Berlin"),
UEBAEnabled: getenvBool("UEBA_ENABLED", true),
UEBALookback: getenvDuration("UEBA_LOOKBACK", 30*24*time.Hour),
UEBANewContextWindow: getenvDuration("UEBA_NEW_CONTEXT_WINDOW", 10*time.Minute),
RiskScoreWindow: getenvDuration("RISK_SCORE_WINDOW", 24*time.Hour),
}
}
@@ -3756,6 +3966,10 @@ func (s *server) runDetectionsOnce() {
{"baseline_anomaly", s.detector.runBaselineAnomalyRule},
{"baseline_update", s.detector.runBaselineUpdate},
{"ueba_new_user_context", s.detector.runUEBANewUserContextRule},
{"ueba_update", s.detector.runUEBABaselineUpdate},
{"host_risk_score", s.detector.runHostRiskScoreUpdate},
}
for _, rule := range rules {
@@ -3770,6 +3984,294 @@ func (s *server) runDetectionsOnce() {
}
}
func (d *detector) runUEBABaselineUpdate(ctx context.Context) error {
if !d.cfg.UEBAEnabled {
return nil
}
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.UEBANewContextWindow)
rows, err := d.db.QueryContext(ctx, `
SELECT target_user, hostname, src_ip, workstation, COUNT(*)
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 target_user, hostname, src_ip, workstation
`, windowStart, windowEnd)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var user, host, srcIP, workstation string
var count int
if err := rows.Scan(&user, &host, &srcIP, &workstation, &count); err != nil {
return err
}
_, err := d.db.ExecContext(ctx, `
INSERT INTO ueba_user_baseline
(username, hostname, src_ip, workstation, 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,
host,
srcIP,
workstation,
count,
)
if err != nil {
return err
}
}
return rows.Err()
}
func (d *detector) runUEBANewUserContextRule(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, e.src_ip, e.workstation, 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.src_ip = e.src_ip
AND b.workstation = e.workstation
AND b.first_seen < ?
AND b.last_seen >= ?
)
GROUP BY e.hostname, e.target_user, e.src_ip, e.workstation
`,
windowStart,
windowEnd,
windowStart,
lookbackStart,
)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, user, srcIP, workstation string
var count int
if err := rows.Scan(&host, &user, &srcIP, &workstation, &count); err != nil {
return err
}
score := 2.0
severity := "medium"
if count >= 5 {
score = 4.0
severity = "high"
}
created, err := d.insertDetection(ctx, Detection{
RuleName: "ueba_new_user_context",
Severity: severity,
Hostname: host,
Channel: "Security",
EventID: 4624,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf(
"UEBA: Benutzer %s meldet sich in neuem Kontext an: Host=%s IP=%s Workstation=%s",
user,
host,
srcIP,
workstation,
),
Details: mustJSON(map[string]any{
"user": user,
"src_ip": srcIP,
"workstation": workstation,
"count": count,
"lookback": d.cfg.UEBALookback.String(),
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("ueba_new_user_context", severity).Inc()
d.anomalyScoreGauge.WithLabelValues(host, "ueba_new_user_context").Set(score)
}
}
return rows.Err()
}
func riskWeight(severity string) float64 {
switch severity {
case "critical":
return 40
case "high":
return 15
case "medium":
return 5
case "low":
return 1
case "info":
return 0.5
default:
return 1
}
}
func severityFromRisk(score float64) string {
switch {
case score >= 80:
return "critical"
case score >= 40:
return "high"
case score >= 15:
return "medium"
case score >= 5:
return "low"
default:
return "info"
}
}
func (d *detector) runHostRiskScoreUpdate(ctx context.Context) error {
windowStart := time.Now().UTC().Add(-d.cfg.RiskScoreWindow)
rows, err := d.db.QueryContext(ctx, `
SELECT
hostname,
severity,
status,
COUNT(*) AS cnt,
MAX(created_at) AS last_detection_at
FROM detections
WHERE created_at >= ?
AND status NOT IN ('false_positive', 'suppressed')
GROUP BY hostname, severity, status
`, windowStart)
if err != nil {
return err
}
defer rows.Close()
type agg struct {
score float64
open int
high int
critical int
confirmedIncidents int
last time.Time
}
stats := map[string]*agg{}
for rows.Next() {
var host, sev, status string
var count int
var last time.Time
if err := rows.Scan(&host, &sev, &status, &count, &last); err != nil {
return err
}
if _, ok := stats[host]; !ok {
stats[host] = &agg{}
}
a := stats[host]
w := riskWeight(sev)
if status == "confirmed_incident" {
w += 50
a.confirmedIncidents += count
}
if status == "open" || status == "investigating" || status == "acknowledged" {
a.open += count
}
if sev == "high" {
a.high += count
}
if sev == "critical" {
a.critical += count
}
a.score += w * float64(count)
if last.After(a.last) {
a.last = last
}
}
if err := rows.Err(); err != nil {
return err
}
for host, a := range stats {
sev := severityFromRisk(a.score)
_, err := d.db.ExecContext(ctx, `
INSERT INTO host_risk_scores
(hostname, risk_score, severity, open_detections, high_detections, critical_detections, confirmed_incidents, last_detection_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
risk_score = VALUES(risk_score),
severity = VALUES(severity),
open_detections = VALUES(open_detections),
high_detections = VALUES(high_detections),
critical_detections = VALUES(critical_detections),
confirmed_incidents = VALUES(confirmed_incidents),
last_detection_at = VALUES(last_detection_at),
updated_at = UTC_TIMESTAMP(6)
`,
host,
a.score,
sev,
a.open,
a.high,
a.critical,
a.confirmedIncidents,
a.last,
)
if err != nil {
return err
}
d.hostRiskScoreGauge.WithLabelValues(host, sev).Set(a.score)
}
return nil
}
func (d *detector) updateAgentMetrics(ctx context.Context) error {
const q = `
SELECT hostname, last_seen