Files
siem-backend/main.go
jbergner a846228e39
All checks were successful
release-tag / release-image (push) Successful in 2m8s
UI-Update
2026-04-26 10:54:44 +02:00

5498 lines
137 KiB
Go

package main
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"html/template"
"io"
"log"
"math"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const uiTemplates = `
{{define "header"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<style>
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--surface-muted: #f8fafc;
--surface-nav: #101828;
--text: #101828;
--muted: #667085;
--border: #d0d5dd;
--border-soft: #eaecf0;
--primary: #175cd3;
--primary-hover: #1849a9;
--danger: #b42318;
--success: #067647;
--warning: #b54708;
--shadow: 0 1px 2px rgba(16, 24, 40, .06);
--radius: 6px;
}
* {
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Roboto, Arial, sans-serif;
margin: 0;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
/* Header */
header {
background: var(--surface-nav);
color: #ffffff;
padding: 0;
border-bottom: 1px solid #1d2939;
position: sticky;
top: 0;
z-index: 20;
}
header > div {
padding: 14px 28px 10px;
border-bottom: 1px solid rgba(255,255,255,.08);
}
header strong {
font-size: 16px;
font-weight: 600;
letter-spacing: .2px;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 0;
padding: 0 20px;
}
nav a {
color: #d0d5dd;
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 12px 12px;
border-bottom: 2px solid transparent;
background: transparent;
border-radius: 0;
}
nav a:hover {
color: #ffffff;
background: rgba(255,255,255,.04);
border-bottom-color: #98a2b3;
}
/* Layout */
main {
padding: 24px 28px;
max-width: 1920px;
margin: 0 auto;
}
h1 {
margin: 0 0 6px;
font-size: 24px;
font-weight: 600;
letter-spacing: -.02em;
}
h2 {
margin: 28px 0 12px;
font-size: 18px;
font-weight: 600;
}
.muted {
color: var(--muted);
}
/* Cards */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.card {
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow);
overflow-wrap: anywhere;
word-break: break-word;
}
.card strong {
font-size: 22px;
font-weight: 600;
}
/* Forms */
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
margin: 16px 0;
background: var(--surface);
padding: 16px;
border-radius: var(--radius);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow);
}
label {
display: block;
font-size: 12px;
color: #344054;
margin-bottom: 5px;
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #ffffff;
color: var(--text);
font-size: 14px;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid rgba(23, 92, 211, .18);
border-color: var(--primary);
}
button {
padding: 8px 12px;
background: var(--primary);
color: #ffffff;
border: 1px solid var(--primary);
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
button:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
button.danger {
background: var(--danger);
border-color: var(--danger);
}
button.success {
background: var(--success);
border-color: var(--success);
}
/* Quick filters - Enterprise Style */
.quickfilters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 12px 0 18px;
}
.quickfilters a {
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 10px;
box-shadow: none;
font-size: 13px;
color: #344054;
font-weight: 500;
}
.quickfilters a:hover {
background: #f9fafb;
border-color: #98a2b3;
color: #101828;
}
/* Tables */
.table-wrap {
width: 100%;
overflow-x: auto;
border-radius: var(--radius);
border: 1px solid var(--border-soft);
background: #ffffff;
box-shadow: var(--shadow);
}
table {
width: 100%;
border-collapse: collapse;
background: #ffffff;
table-layout: auto;
}
th,
td {
text-align: left;
padding: 9px 11px;
border-bottom: 1px solid var(--border-soft);
vertical-align: top;
max-width: 560px;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.35;
}
th {
background: #f9fafb;
color: #475467;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
position: static;
z-index: auto;
}
tbody tr:hover td {
background: #f9fafb;
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
border-radius: 4px;
padding: 3px 7px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;
}
.sev-critical {
background: #fef3f2;
color: #912018;
border-color: #fecdca;
}
.sev-high {
background: #fff4ed;
color: #9c2a10;
border-color: #ffd6ae;
}
.sev-medium {
background: #fffaeb;
color: #93370d;
border-color: #fedf89;
}
.sev-low {
background: #ecfdf3;
color: #05603a;
border-color: #abefc6;
}
.sev-info {
background: #eff8ff;
color: #175cd3;
border-color: #b2ddff;
}
.status-open {
background: #eff8ff;
color: #175cd3;
border-color: #b2ddff;
}
.status-acknowledged {
background: #f4f3ff;
color: #5925dc;
border-color: #d9d6fe;
}
.status-investigating {
background: #fffaeb;
color: #93370d;
border-color: #fedf89;
}
.status-plausible {
background: #f0f9ff;
color: #026aa2;
border-color: #b9e6fe;
}
.status-legitimate {
background: #ecfdf3;
color: #05603a;
border-color: #abefc6;
}
.status-false_positive {
background: #f2f4f7;
color: #344054;
border-color: #d0d5dd;
}
.status-resolved {
background: #f0fdfa;
color: #0f766e;
border-color: #99f6e4;
}
.status-suppressed {
background: #f2f4f7;
color: #475467;
border-color: #d0d5dd;
}
.status-confirmed_incident {
background: #fef3f2;
color: #912018;
border-color: #fecdca;
}
/* Utility */
.wrap {
overflow-wrap: anywhere;
word-break: break-word;
}
.mono {
font-family: Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
}
.note {
margin-top: 8px;
color: #344054;
font-size: 13px;
padding: 8px 10px;
background: #f9fafb;
border-left: 3px solid #98a2b3;
border-radius: 0 var(--radius) var(--radius) 0;
}
pre {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
background: #101828;
color: #f2f4f7;
padding: 16px;
border-radius: var(--radius);
overflow-x: auto;
border: 1px solid #344054;
}
a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
a:hover {
text-decoration: underline;
}
.mini-form {
display: grid;
grid-template-columns: minmax(120px, 1fr);
gap: 6px;
min-width: 220px;
}
.inline-form {
display: inline;
}
.checkline {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #475467;
}
.checkline input {
width: auto;
}
/* Agent status aliases */
.badge-on {
background: #ecfdf3;
color: #05603a;
border-color: #abefc6;
}
.badge-off {
background: #fef3f2;
color: #912018;
border-color: #fecdca;
}
.badge-muted {
background: #f2f4f7;
color: #344054;
border-color: #d0d5dd;
}
/* Responsive */
@media (max-width: 900px) {
main {
padding: 16px;
}
header > div {
padding: 12px 16px 8px;
}
nav {
padding: 0 10px;
}
nav a {
padding: 10px 8px;
}
.card strong {
font-size: 20px;
}
}
</style>
</head>
<body>
<header>
<div><strong>SIEM-lite Security Operations</strong></div>
<nav>
<a href="/ui/soc">SOC</a>
<a href="/ui">Overview</a>
<a href="/ui/agents">Agents</a>
<a href="/ui/rules">Rules</a>
<a href="/ui/baseline">Baseline</a>
<a href="/ui/detections">Detections</a>
<a href="/ui/events">Events</a>
<a href="/metrics">Metrics</a>
</nav>
</header>
<main>
{{end}}
{{define "footer"}}
</main>
</body>
</html>
{{end}}
{{define "dashboard"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<p class="muted">Stand: {{fmtTime .Now}}</p>
<div class="grid">
<div class="card"><div class="muted">Agents gesamt</div><div><strong>{{.Stats.AgentsTotal}}</strong></div></div>
<div class="card"><div class="muted">Agents aktiv</div><div><strong>{{.Stats.AgentsActive}}</strong></div></div>
<div class="card"><div class="muted">Events 24h</div><div><strong>{{.Stats.Events24h}}</strong></div></div>
<div class="card"><div class="muted">Detections 24h</div><div><strong>{{.Stats.Detections24h}}</strong></div></div>
<div class="card"><div class="muted">High Detections 24h</div><div><strong>{{.Stats.HighDetections24h}}</strong></div></div>
<div class="card"><div class="muted">Open Detections</div><div><strong>{{.Stats.OpenDetections}}</strong></div></div>
<div class="card"><div class="muted">Investigating</div><div><strong>{{.Stats.InvestigatingDetections}}</strong></div></div>
<div class="card"><div class="muted">Critical 24h</div><div><strong>{{.Stats.CriticalDetections24h}}</strong></div></div>
<div class="card"><div class="muted">False Positive 24h</div><div><strong>{{.Stats.FalsePositive24h}}</strong></div></div>
<div class="card"><div class="muted">Legitim 24h</div><div><strong>{{.Stats.Legitimate24h}}</strong></div></div>
</div>
<h2>Neueste Detections</h2>
<div class="table-wrap">
<table>
<tr><th>Zeit</th><th>Rule</th><th>Severity</th><th>Host</th><th>Zusammenfassung</th></tr>
{{range .RecentDetections}}
<tr>
<td>{{fmtTime .CreatedAt}}</td>
<td><a href="/ui/events?host={{q .Hostname}}&rule={{q .RuleName}}">{{.RuleName}}</a></td>
<td class="sev-{{.Severity}}">{{.Severity}}</td>
<td>{{.Hostname}}</td>
<td>{{.Summary}}</td>
</tr>
{{end}}
</table>
</div>
<h2>Neueste Events</h2>
<div class="table-wrap">
<table>
<tr><th>Zeit</th><th>Host</th><th>Channel</th><th>EventID</th><th>User</th><th>IP</th><th>Nachricht</th></tr>
{{range .RecentEvents}}
<tr>
<td>{{fmtTime .Time}}</td>
<td>{{.Hostname}}</td>
<td>{{.Channel}}</td>
<td><a href="/ui/events?event_id={{.EventID}}">{{.EventID}}</a></td>
<td>{{if .TargetUser}}{{.TargetUser}}{{else}}{{.SubjectUser}}{{end}}</td>
<td>{{.SrcIP}}</td>
<td><a href="/ui/event?id={{.ID}}">{{short .Message 120}}</a></td>
</tr>
{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}
{{define "detections"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<form method="get" action="/ui/detections">
<div class="filters">
<div><label>Host</label><input name="host" value="{{index .Filters "host"}}"></div>
<div><label>Rule</label><input name="rule" value="{{index .Filters "rule"}}"></div>
<div><label>Severity</label><input name="severity" value="{{index .Filters "severity"}}"></div>
<div>
<label>Status</label>
<select name="status">
<option value="">alle</option>
<option value="open" {{if eq (index .Filters "status") "open"}}selected{{end}}>open</option>
<option value="acknowledged" {{if eq (index .Filters "status") "acknowledged"}}selected{{end}}>acknowledged</option>
<option value="investigating" {{if eq (index .Filters "status") "investigating"}}selected{{end}}>investigating</option>
<option value="legitimate" {{if eq (index .Filters "status") "legitimate"}}selected{{end}}>legitim</option>
<option value="plausible" {{if eq (index .Filters "status") "plausible"}}selected{{end}}>plausible</option>
<option value="false_positive" {{if eq (index .Filters "status") "false_positive"}}selected{{end}}>false positive</option>
<option value="resolved" {{if eq (index .Filters "status") "resolved"}}selected{{end}}>resolved</option>
<option value="suppressed" {{if eq (index .Filters "status") "suppressed"}}selected{{end}}>suppressed</option>
<option value="confirmed_incident" {{if eq (index .Filters "status") "confirmed_incident"}}selected{{end}}>confirmed incident</option>
</select>
</div>
<div><label>Limit</label><input name="limit" value="{{index .Filters "limit"}}"></div>
</div>
<button type="submit">Filtern</button>
</form>
<div class="quickfilters">
<a href="/ui/detections?status=open">Open</a>
<a href="/ui/detections?severity=critical">Critical</a>
<a href="/ui/detections?severity=high">High</a>
<a href="/ui/detections?status=investigating">Investigating</a>
<a href="/ui/detections?status=false_positive">False Positives</a>
<a href="/ui/detections?status=legitimate">Legitim</a>
<a href="/ui/detections?status=plausible">Plausibel</a>
<a href="/ui/detections?status=resolved">Resolved</a>
<a href="/ui/detections?status=confirmed_incident">Confirmed Incidents</a>
</div>
<div class="card">
<h2>Batch-Bewertung</h2>
<p class="muted">Bearbeitet mehrere Detections anhand von Rule, Host, Channel, EventID oder Zeitfenster.</p>
<form method="post" action="/ui/detections/batch-update">
<div class="filters">
<div><label>Rule</label><input name="rule" value="{{index .Filters "rule"}}"></div>
<div><label>Host</label><input name="host" value="{{index .Filters "host"}}"></div>
<div><label>Channel</label><input name="channel"></div>
<div><label>Event ID</label><input name="event_id"></div>
<div><label>From RFC3339</label><input name="from" placeholder="2026-04-26T08:00:00+02:00"></div>
<div><label>To RFC3339</label><input name="to" placeholder="2026-04-26T12:00:00+02:00"></div>
<div><label>Limit</label><input name="limit" value="5000"></div>
<div>
<label>Status</label>
<select name="status">
<option value="plausible">plausibel</option>
<option value="legitimate">legitim</option>
<option value="false_positive">false positive</option>
<option value="resolved">resolved</option>
<option value="suppressed">suppressed</option>
<option value="acknowledged">acknowledged</option>
</select>
</div>
</div>
<label>Notiz</label>
<textarea name="note" rows="3" placeholder="z. B. Patchday, Software-Rollout, Wartungsfenster"></textarea>
<p>
<button type="submit">Batch anwenden</button>
</p>
</form>
</div>
<div class="table-wrap">
<table>
<tr>
<th>Zeit</th>
<th>Rule</th>
<th>Severity</th>
<th>Status</th>
<th>Host</th>
<th>Score</th>
<th>Summary</th>
<th>Bewertung</th>
<th>Events</th>
</tr>
{{range .Detections}}
<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>{{.Hostname}}</td>
<td>{{printf "%.2f" .Score}}</td>
<td class="wrap">
{{.Summary}}
{{if .AnalystNote}}
<div class="note">Notiz: {{.AnalystNote}}</div>
{{end}}
</td>
<td>
<form method="post" action="/ui/detection/update" class="mini-form">
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="redirect" value="/ui/detections">
<select name="status">
<option value="open" {{if eq .Status "open"}}selected{{end}}>open</option>
<option value="acknowledged" {{if eq .Status "acknowledged"}}selected{{end}}>ack</option>
<option value="investigating" {{if eq .Status "investigating"}}selected{{end}}>investigating</option>
<option value="legitimate" {{if eq .Status "legitimate"}}selected{{end}}>legitim</option>
<option value="plausible" {{if eq .Status "plausible"}}selected{{end}}>plausibel</option>
<option value="false_positive" {{if eq .Status "false_positive"}}selected{{end}}>false positive</option>
<option value="resolved" {{if eq .Status "resolved"}}selected{{end}}>resolved</option>
<option value="suppressed" {{if eq .Status "suppressed"}}selected{{end}}>suppressed</option>
<option value="confirmed_incident" {{if eq .Status "confirmed_incident"}}selected{{end}}>confirmed incident</option>
</select>
<label class="checkline">
<input type="checkbox" name="create_suppression" value="1">
künftig unterdrücken
</label>
<select name="suppress_hours">
<option value="24">24h</option>
<option value="168">7 Tage</option>
<option value="720">30 Tage</option>
<option value="0">dauerhaft</option>
</select>
<label>Baseline</label>
<select name="baseline_action">
<option value="">keine Änderung</option>
<option value="exclude_24h">nicht lernen: 24h</option>
<option value="exclude_7d">nicht lernen: 7 Tage</option>
<option value="exclude_30d">nicht lernen: 30 Tage</option>
<option value="exclude_forever">nicht lernen: dauerhaft</option>
</select>
<input name="note" placeholder="Notiz" value="{{.AnalystNote}}">
<button type="submit">Speichern</button>
</form>
<form method="post" action="/ui/detections/batch-update" class="mini-form">
<input type="hidden" name="rule" value="{{.RuleName}}">
<input type="hidden" name="host" value="{{.Hostname}}">
<input type="hidden" name="channel" value="{{.Channel}}">
<input type="hidden" name="event_id" value="{{.EventID}}">
<input type="hidden" name="status" value="plausible">
<input type="hidden" name="note" value="Gleicher Typ wurde gesammelt als plausibel markiert">
<input type="hidden" name="limit" value="5000">
<button type="submit">Ähnliche plausibel</button>
</form>
</td>
<td>
<a href="/ui/events?host={{q .Hostname}}&rule={{q .RuleName}}&severity={{q .Severity}}">anzeigen</a>
</td>
</tr>
{{end}}
</table>
</div>
{{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>
<div class="table-wrap">
<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>
</div>
{{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>
<p class="muted">Baseline-Anomalien aus der Regel <strong>baseline_event_rate_anomaly</strong>.</p>
<form method="get" action="/ui/baseline">
<div class="filters">
<div><label>Host</label><input name="host" value="{{index .Filters "host"}}"></div>
<div><label>Channel</label><input name="channel" value="{{index .Filters "channel"}}"></div>
<div><label>Event ID</label><input name="event_id" value="{{index .Filters "event_id"}}"></div>
<div><label>Severity</label><input name="severity" value="{{index .Filters "severity"}}"></div>
<div><label>Limit</label><input name="limit" value="{{index .Filters "limit"}}"></div>
</div>
<button type="submit">Filtern</button>
</form>
<div class="table-wrap">
<table>
<tr>
<th>Zeit</th>
<th>Host</th>
<th>Channel</th>
<th>EventID</th>
<th>Severity</th>
<th>Aktuell</th>
<th>Baseline</th>
<th>Z-Score</th>
<th>Samples</th>
<th>Bucket</th>
</tr>
{{range .Anomalies}}
<tr>
<td>{{fmtTime .CreatedAt}}</td>
<td>{{.Hostname}}</td>
<td>{{.Channel}}</td>
<td><a href="/ui/events?host={{q .Hostname}}&channel={{q .Channel}}&event_id={{.EventID}}">{{.EventID}}</a></td>
<td class="sev-{{.Severity}}">{{.Severity}}</td>
<td><strong>{{.Count}}</strong></td>
<td>{{printf "%.2f" .AvgCount}} ± {{printf "%.2f" .StddevCount}}</td>
<td><strong>{{printf "%.2f" .ZScore}}</strong></td>
<td>{{.SampleCount}}</td>
<td>Tag {{.DayOfWeek}}, Stunde {{.HourOfDay}}</td>
</tr>
{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}
{{define "events"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<form method="get" action="/ui/events">
<div class="filters">
<div><label>Host</label><input name="host" value="{{index .Filters "host"}}"></div>
<div><label>Channel</label><input name="channel" value="{{index .Filters "channel"}}"></div>
<div><label>Event ID</label><input name="event_id" value="{{index .Filters "event_id"}}"></div>
<div><label>User</label><input name="user" value="{{index .Filters "user"}}"></div>
<div><label>Source IP</label><input name="src_ip" value="{{index .Filters "src_ip"}}"></div>
<div><label>Rule</label><input name="rule" value="{{index .Filters "rule"}}"></div>
<div><label>Severity</label><input name="severity" value="{{index .Filters "severity"}}"></div>
<div><label>From (RFC3339)</label><input name="from" value="{{index .Filters "from"}}"></div>
<div><label>To (RFC3339)</label><input name="to" value="{{index .Filters "to"}}"></div>
<div><label>Limit</label><input name="limit" value="{{index .Filters "limit"}}"></div>
</div>
<button type="submit">Filtern</button>
</form>
<div class="table-wrap">
<table>
<tr>
<th>Zeit</th><th>Host</th><th>Channel</th><th>EventID</th><th>Target User</th><th>Subject User</th><th>IP</th><th>Workstation</th><th>Detail</th>
</tr>
{{range .Events}}
<tr>
<td>{{fmtTime .Time}}</td>
<td>{{.Hostname}}</td>
<td>{{.Channel}}</td>
<td>{{.EventID}}</td>
<td>{{.TargetUser}}</td>
<td>{{.SubjectUser}}</td>
<td>{{.SrcIP}}</td>
<td>{{.Workstation}}</td>
<td><a href="/ui/event?id={{.ID}}">öffnen</a></td>
</tr>
{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}
{{define "agents"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<p class="muted">Stand: {{fmtTime .Now}}</p>
<div class="table-wrap">
<table>
<tr>
<th>Hostname</th>
<th>Status</th>
<th>Aktiviert</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Offline Minuten</th>
<th>Last IP</th>
<th>Aktion</th>
</tr>
{{range .Agents}}
<tr>
<td><strong>{{.Hostname}}</strong></td>
<td>
{{if .IsOnline}}
<span class="badge badge-on">online</span>
{{else}}
<span class="badge badge-off">offline</span>
{{end}}
</td>
<td>
{{if .IsEnabled}}
<span class="badge badge-on">aktiv</span>
{{else}}
<span class="badge badge-muted">inaktiv</span>
{{end}}
</td>
<td>{{fmtTime .FirstSeen}}</td>
<td>{{fmtTime .LastSeen}}</td>
<td>{{.OfflineMinutes}}</td>
<td>{{.LastIP}}</td>
<td>
<form class="inline-form" method="post" action="/ui/agents/toggle">
<input type="hidden" name="id" value="{{.ID}}">
{{if .IsEnabled}}
<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 "event_detail"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<div class="grid">
<div class="card"><strong>Host</strong><div>{{.Event.Hostname}}</div></div>
<div class="card"><strong>Channel</strong><div>{{.Event.Channel}}</div></div>
<div class="card"><strong>EventID</strong><div>{{.Event.EventID}}</div></div>
<div class="card"><strong>Zeit</strong><div>{{fmtTime .Event.Time}}</div></div>
<div class="card"><strong>Target User</strong><div>{{.Event.TargetUser}}</div></div>
<div class="card"><strong>Subject User</strong><div>{{.Event.SubjectUser}}</div></div>
<div class="card"><strong>Source IP</strong><div>{{.Event.SrcIP}}</div></div>
<div class="card"><strong>Workstation</strong><div>{{.Event.Workstation}}</div></div>
<div class="card"><strong>Logon Type</strong><div>{{.Event.LogonType}}</div></div>
<div class="card"><strong>Process</strong><div>{{.Event.ProcessName}}</div></div>
<div class="card"><strong>Status</strong><div>{{.Event.StatusText}}</div></div>
<div class="card"><strong>SubStatus</strong><div>{{.Event.SubStatusText}}</div></div>
</div>
<h2>Rohes Event XML</h2>
<pre>{{.Event.Message}}</pre>
{{template "footer" .}}
{{end}}
`
type Config struct {
ListenAddr string
DBDSN string
MaxBodyBytes int64
HTTPReadTimeout time.Duration
HTTPWriteTimeout time.Duration
HTTPIdleTimeout time.Duration
DBMaxOpenConns int
DBMaxIdleConns int
DBConnMaxLifetime time.Duration
DBConnMaxIdleTime time.Duration
DetectionInterval time.Duration
OfflineAfter time.Duration
OfflineAlertMax time.Duration
FailedLogonWindow time.Duration
FailedLogonThreshold int
RebootWindow time.Duration
RebootThreshold int
PasswordSprayWindow time.Duration
PasswordSprayMinUsers int
PasswordSprayMinAttempts int
SuccessAfterFailureWindow time.Duration
NewSourceIPLookback time.Duration
NewSourceIPWindow time.Duration
DetectionsLimit int
EnrollmentKey string
BaselineEnabled bool
BaselineWindow time.Duration
BaselineMinSamples int
BaselineMinCount int
BaselineMediumZScore float64
BaselineHighZScore float64
BaselineSuppressFor time.Duration
Timezone string
UEBAEnabled bool
UEBALookback time.Duration
UEBANewContextWindow time.Duration
RiskScoreWindow time.Duration
}
type LogPayload struct {
Hostname string `json:"host"`
Channel string `json:"channel"`
EventID uint32 `json:"id"`
Source string `json:"source"`
Time time.Time `json:"ts"`
Message string `json:"msg"`
}
type NormalizedEvent struct {
Computer string
ProviderName string
LevelValue uint32
TaskValue uint32
OpcodeValue uint32
Keywords string
TargetUser string
TargetDomain string
SubjectUser string
SubjectDomain string
Workstation string
SrcIP string
SrcPort string
LogonType string
ProcessName string
AuthenticationPackage string
LogonProcess string
StatusText string
SubStatusText string
FailureReason string
MemberName string
GroupName string
}
type Detection struct {
ID uint64 `json:"id"`
RuleName string `json:"rule_name"`
Severity string `json:"severity"`
Hostname string `json:"hostname"`
Channel string `json:"channel"`
EventID uint32 `json:"event_id"`
Score float64 `json:"score"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Summary string `json:"summary"`
Details json.RawMessage `json:"details_json"`
CreatedAt time.Time `json:"created_at"`
Status string
AnalystNote string
ReviewedBy string
ReviewedAt sql.NullTime
IsFalsePositive bool
IsLegitimate bool
}
type ingestResponse struct {
Accepted int `json:"accepted"`
}
type server struct {
db *sql.DB
logger *log.Logger
cfg Config
registry *prometheus.Registry
detector *detector
startTime time.Time
templates *template.Template
location *time.Location
}
type detector struct {
db *sql.DB
cfg Config
logger *log.Logger
lastSeenGauge *prometheus.GaugeVec
activeAgentsGauge prometheus.Gauge
anomalyScoreGauge *prometheus.GaugeVec
detectionHitsTotal *prometheus.CounterVec
ruleLastRunGauge *prometheus.GaugeVec
ruleRuntimeHist *prometheus.HistogramVec
ruleErrorsTotal *prometheus.CounterVec
baselineCurrentCountGauge *prometheus.GaugeVec
baselineAverageGauge *prometheus.GaugeVec
baselineStddevGauge *prometheus.GaugeVec
baselineSamplesGauge *prometheus.GaugeVec
hostRiskScoreGauge *prometheus.GaugeVec
}
type EventRow struct {
ID uint64
Hostname string
Channel string
EventID uint32
Source string
Computer string
ProviderName string
TargetUser string
TargetDomain string
SubjectUser string
SubjectDomain string
Workstation string
SrcIP string
SrcPort string
LogonType string
ProcessName string
AuthenticationPackage string
LogonProcess string
StatusText string
SubStatusText string
FailureReason string
Time time.Time
ReceivedAt time.Time
Message string
}
type DashboardStats struct {
AgentsTotal int
AgentsActive int
Events24h int64
Detections24h int64
HighDetections24h int64
OpenDetections int64
InvestigatingDetections int64
CriticalDetections24h int64
FalsePositive24h int64
Legitimate24h int64
}
type DashboardPageData struct {
Title string
Now time.Time
Stats DashboardStats
RecentDetections []Detection
RecentEvents []EventRow
}
type DetectionListPageData struct {
Title string
Now time.Time
Filters map[string]string
Detections []Detection
}
type EventListPageData struct {
Title string
Now time.Time
Filters map[string]string
Events []EventRow
}
type EventDetailPageData struct {
Title string
Now time.Time
Event EventRow
}
type AgentRow struct {
ID uint64
Hostname string
FirstSeen time.Time
LastSeen time.Time
LastIP string
IsEnabled bool
IsOnline bool
OfflineMinutes int
}
type AgentListPageData struct {
Title string
Now time.Time
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
}
type BaselineBucket struct {
Hostname string
Channel string
EventID uint32
Hour int
DayOfWeek int
Count int
}
type BaselineStat struct {
AvgCount float64
M2Count float64
StddevCount float64
SampleCount int
}
type BaselineAnomalyRow struct {
ID uint64
CreatedAt time.Time
Hostname string
Channel string
EventID uint32
Severity string
Score float64
WindowStart time.Time
WindowEnd time.Time
Summary string
Count int
AvgCount float64
StddevCount float64
ZScore float64
SampleCount int
HourOfDay int
DayOfWeek int
WindowMin int
}
type BaselinePageData struct {
Title string
Now time.Time
Filters map[string]string
Anomalies []BaselineAnomalyRow
}
type baselineDetailsJSON struct {
Hostname string `json:"hostname"`
Channel string `json:"channel"`
EventID uint32 `json:"event_id"`
Count int `json:"count"`
AvgCount float64 `json:"avg_count"`
StddevCount float64 `json:"stddev_count"`
ZScore float64 `json:"z_score"`
SampleCount int `json:"sample_count"`
HourOfDay int `json:"hour_of_day"`
DayOfWeek int `json:"day_of_week"`
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."},
[]string{"path", "method", "status"},
)
httpRequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "eventcollector_http_request_duration_seconds", Help: "HTTP request latency.", Buckets: prometheus.DefBuckets},
[]string{"path", "method", "status"},
)
ingestBatchesTotal = prometheus.NewCounter(
prometheus.CounterOpts{Name: "eventcollector_ingest_batches_total", Help: "Total ingested batches."},
)
ingestEventsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_ingest_events_total", Help: "Total ingested events."},
[]string{"channel", "event_id"},
)
ingestRejectedTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_ingest_rejected_total", Help: "Rejected ingest requests."},
[]string{"reason"},
)
dbInsertEventsTotal = prometheus.NewCounter(
prometheus.CounterOpts{Name: "eventcollector_db_insert_events_total", Help: "Total inserted events into database."},
)
dbInsertFailuresTotal = prometheus.NewCounter(
prometheus.CounterOpts{Name: "eventcollector_db_insert_failures_total", Help: "Failed database insert operations."},
)
dbBatchSizeHist = prometheus.NewHistogram(
prometheus.HistogramOpts{Name: "eventcollector_db_batch_size", Help: "Batch sizes written to database.", Buckets: []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000}},
)
dbTxDurationHist = prometheus.NewHistogram(
prometheus.HistogramOpts{Name: "eventcollector_db_tx_duration_seconds", Help: "Database transaction duration.", Buckets: prometheus.DefBuckets},
)
)
func main() {
cfg := loadConfig()
logger := log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds|log.LUTC)
loc, err := time.LoadLocation(cfg.Timezone)
if err != nil {
logger.Printf("invalid TZ %q, falling back to Local: %v", cfg.Timezone, err)
loc = time.Local
}
db, err := sql.Open("mysql", cfg.DBDSN)
if err != nil {
logger.Fatalf("sql.Open: %v", err)
}
db.SetMaxOpenConns(cfg.DBMaxOpenConns)
db.SetMaxIdleConns(cfg.DBMaxIdleConns)
db.SetConnMaxLifetime(cfg.DBConnMaxLifetime)
db.SetConnMaxIdleTime(cfg.DBConnMaxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
logger.Fatalf("db.PingContext: %v", err)
}
reg := prometheus.NewRegistry()
reg.MustRegister(
httpRequestsTotal,
httpRequestDuration,
ingestBatchesTotal,
ingestEventsTotal,
ingestRejectedTotal,
dbInsertEventsTotal,
dbInsertFailuresTotal,
dbBatchSizeHist,
dbTxDurationHist,
)
d := &detector{
db: db,
cfg: cfg,
logger: logger,
lastSeenGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{Name: "eventcollector_agent_last_seen_unixtime", Help: "Unix time when agent was last seen."},
[]string{"host"},
),
activeAgentsGauge: prometheus.NewGauge(
prometheus.GaugeOpts{Name: "eventcollector_active_agents", Help: "Number of active agents seen within offline threshold."},
),
anomalyScoreGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{Name: "eventcollector_anomaly_score", Help: "Current anomaly score per host and rule."},
[]string{"host", "rule"},
),
detectionHitsTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_detection_hits_total", Help: "Total number of detections per rule and severity."},
[]string{"rule", "severity"},
),
ruleLastRunGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{Name: "eventcollector_rule_last_run_unixtime", Help: "Unix time of the last successful rule run."},
[]string{"rule"},
),
ruleRuntimeHist: prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "eventcollector_rule_runtime_seconds", Help: "Rule runtime duration.", Buckets: prometheus.DefBuckets},
[]string{"rule"},
),
ruleErrorsTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_rule_errors_total", Help: "Rule execution errors."},
[]string{"rule"},
),
baselineCurrentCountGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "eventcollector_baseline_current_count",
Help: "Current event count in baseline window.",
},
[]string{"host", "channel", "event_id"},
),
baselineAverageGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "eventcollector_baseline_avg_count",
Help: "Baseline average event count.",
},
[]string{"host", "channel", "event_id"},
),
baselineStddevGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "eventcollector_baseline_stddev_count",
Help: "Baseline standard deviation event count.",
},
[]string{"host", "channel", "event_id"},
),
baselineSamplesGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "eventcollector_baseline_sample_count",
Help: "Baseline sample count.",
},
[]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,
d.activeAgentsGauge,
d.anomalyScoreGauge,
d.detectionHitsTotal,
d.ruleLastRunGauge,
d.ruleRuntimeHist,
d.ruleErrorsTotal,
d.baselineCurrentCountGauge,
d.baselineAverageGauge,
d.baselineStddevGauge,
d.baselineSamplesGauge,
d.hostRiskScoreGauge,
)
s := &server{
db: db,
logger: logger,
cfg: cfg,
registry: reg,
detector: d,
startTime: time.Now().UTC(),
location: loc,
}
tmpl := template.Must(template.New("ui").Funcs(template.FuncMap{
"q": url.QueryEscape,
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.In(loc).Format("2006-01-02 15:04:05 MST")
},
"short": func(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
},
"eq": func(a, b string) bool {
return a == b
},
}).Parse(uiTemplates))
s.templates = tmpl
go s.runDetectionLoop()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/readyz", s.handleReadyz)
mux.HandleFunc("/ingest", s.handleIngest)
mux.HandleFunc("/detections", s.handleDetections)
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
mux.HandleFunc("/ui", s.handleUIIndex)
mux.HandleFunc("/ui/detections", s.handleUIDetections)
mux.HandleFunc("/ui/detection/update", s.handleUIDetectionUpdate)
mux.HandleFunc("/ui/events", s.handleUIEvents)
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)
mux.HandleFunc("/ui/baseline", s.handleUIBaseline)
mux.HandleFunc("/ui/soc", s.handleUISOC)
mux.HandleFunc("/ui/detections/batch-update", s.handleUIDetectionsBatchUpdate)
httpSrv := &http.Server{
Addr: cfg.ListenAddr,
Handler: metricsMiddleware(logger, recoveryMiddleware(mux)),
ReadTimeout: cfg.HTTPReadTimeout,
WriteTimeout: cfg.HTTPWriteTimeout,
IdleTimeout: cfg.HTTPIdleTimeout,
}
go func() {
logger.Printf("listening on %s", cfg.ListenAddr)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatalf("ListenAndServe: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
logger.Println("shutdown requested")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
logger.Printf("http shutdown error: %v", err)
}
if err := db.Close(); err != nil {
logger.Printf("db close error: %v", err)
}
}
func (s *server) handleUIDetectionsBatchUpdate(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
}
status := strings.TrimSpace(r.FormValue("status"))
note := strings.TrimSpace(r.FormValue("note"))
reviewedBy := strings.TrimSpace(r.FormValue("reviewed_by"))
if reviewedBy == "" {
reviewedBy = "ui-batch"
}
switch status {
case "open", "acknowledged", "investigating", "legitimate", "plausible", "false_positive", "resolved", "suppressed", "confirmed_incident":
default:
writeError(w, http.StatusBadRequest, "invalid status")
return
}
rule := strings.TrimSpace(r.FormValue("rule"))
host := strings.TrimSpace(r.FormValue("host"))
channel := strings.TrimSpace(r.FormValue("channel"))
var eventID uint32
if v := strings.TrimSpace(r.FormValue("event_id")); v != "" {
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid event_id")
return
}
eventID = uint32(n)
}
from := strings.TrimSpace(r.FormValue("from"))
to := strings.TrimSpace(r.FormValue("to"))
limit := atoiDefault(r.FormValue("limit"), 5000)
if limit <= 0 || limit > 50000 {
limit = 5000
}
if rule == "" && host == "" && channel == "" && eventID == 0 && from == "" && to == "" {
writeError(w, http.StatusBadRequest, "at least one filter required")
return
}
isFalsePositive := status == "false_positive"
isLegitimate := status == "legitimate" || status == "plausible"
query := `
UPDATE detections
SET status = ?,
analyst_note = ?,
reviewed_by = ?,
reviewed_at = UTC_TIMESTAMP(6),
is_false_positive = ?,
is_legitimate = ?
WHERE id IN (
SELECT id FROM (
SELECT id
FROM detections
WHERE 1=1
`
args := []any{
status,
note,
reviewedBy,
isFalsePositive,
isLegitimate,
}
if rule != "" {
query += " AND rule_name = ?"
args = append(args, rule)
}
if host != "" {
query += " AND hostname = ?"
args = append(args, host)
}
if channel != "" {
query += " AND channel_name = ?"
args = append(args, channel)
}
if eventID != 0 {
query += " AND event_id = ?"
args = append(args, eventID)
}
if from != "" {
if t, err := parseUIRFC3339(from); err == nil {
query += " AND created_at >= ?"
args = append(args, t.UTC())
}
}
if to != "" {
if t, err := parseUIRFC3339(to); err == nil {
query += " AND created_at <= ?"
args = append(args, t.UTC())
}
}
query += `
ORDER BY created_at DESC
LIMIT ?
) x
)
`
args = append(args, limit)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
res, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
s.logger.Printf("batch update detections: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
affected, _ := res.RowsAffected()
http.Redirect(w, r, fmt.Sprintf("/ui/detections?status=%s&updated=%d", url.QueryEscape(status), affected), http.StatusSeeOther)
}
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 {
expiresAt = time.Now().UTC().Add(time.Duration(hours) * time.Hour)
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO baseline_exclusions
(hostname, channel_name, event_id, reason, created_by, expires_at, enabled)
VALUES (?, ?, ?, ?, ?, ?, 1)
`,
det.Hostname,
det.Channel,
det.EventID,
reason,
createdBy,
expiresAt,
)
return err
}
func (s *server) handleUIDetectionUpdate(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
}
status := strings.TrimSpace(r.FormValue("status"))
note := strings.TrimSpace(r.FormValue("note"))
reviewedBy := strings.TrimSpace(r.FormValue("reviewed_by"))
if reviewedBy == "" {
reviewedBy = "ui"
}
switch status {
case "open", "acknowledged", "investigating", "legitimate", "plausible", "false_positive", "resolved", "suppressed", "confirmed_incident":
default:
writeError(w, http.StatusBadRequest, "invalid status")
return
}
isFalsePositive := status == "false_positive"
isLegitimate := status == "legitimate"
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_, err = s.db.ExecContext(ctx, `
UPDATE detections
SET status = ?,
analyst_note = ?,
reviewed_by = ?,
reviewed_at = UTC_TIMESTAMP(6),
is_false_positive = ?,
is_legitimate = ?
WHERE id = ?
`,
status,
note,
reviewedBy,
isFalsePositive,
isLegitimate,
id,
)
if err != nil {
s.logger.Printf("update detection: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
createSuppression := strings.TrimSpace(r.FormValue("create_suppression")) == "1"
if createSuppression && (status == "false_positive" || status == "legitimate" || status == "suppressed") {
det, err := s.getDetectionByID(ctx, id)
if err != nil {
s.logger.Printf("get detection for suppression: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
hours := atoiDefault(r.FormValue("suppress_hours"), 24)
reason := note
if reason == "" {
reason = fmt.Sprintf("Suppression via UI wegen Status %s", status)
}
if err := s.createSuppressionFromDetection(ctx, det, reason, reviewedBy, hours); err != nil {
s.logger.Printf("create suppression: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
}
baselineAction := strings.TrimSpace(r.FormValue("baseline_action"))
if baselineAction != "" {
det, err := s.getDetectionByID(ctx, id)
if err != nil {
s.logger.Printf("get detection for baseline exclusion: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
hours := 0
switch baselineAction {
case "exclude_24h":
hours = 24
case "exclude_7d":
hours = 168
case "exclude_30d":
hours = 720
case "exclude_forever":
hours = 0
default:
writeError(w, http.StatusBadRequest, "invalid baseline action")
return
}
reason := note
if reason == "" {
reason = fmt.Sprintf("Baseline exclusion via UI wegen Status %s", status)
}
if err := s.createBaselineExclusionFromDetection(ctx, det, reason, reviewedBy, hours); err != nil {
s.logger.Printf("create baseline exclusion: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
}
if status == "confirmed_incident" && baselineAction == "" {
det, err := s.getDetectionByID(ctx, id)
if err != nil {
s.logger.Printf("get detection for confirmed incident exclusion: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
reason := note
if reason == "" {
reason = "Confirmed incident: nicht in Baseline einlernen"
}
if err := s.createBaselineExclusionFromDetection(ctx, det, reason, reviewedBy, 168); err != nil {
s.logger.Printf("create confirmed incident baseline exclusion: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
}
redirect := strings.TrimSpace(r.FormValue("redirect"))
if redirect == "" {
redirect = "/ui/detections"
}
http.Redirect(w, r, redirect, http.StatusSeeOther)
}
func (d *detector) isBaselineExcluded(ctx context.Context, hostname, channel string, eventID uint32) (bool, error) {
var count int
err := d.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM baseline_exclusions
WHERE enabled = 1
AND (hostname = '' OR hostname = ?)
AND (channel_name = '' OR channel_name = ?)
AND (event_id = 0 OR event_id = ?)
AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP(6))
`,
hostname,
channel,
eventID,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *server) getDetectionByID(ctx context.Context, id uint64) (Detection, error) {
const q = `
SELECT id, rule_name, severity, hostname, channel_name, event_id, score,
window_start, window_end, summary, details_json, created_at,
status,
COALESCE(analyst_note, ''),
COALESCE(reviewed_by, ''),
reviewed_at,
is_false_positive,
is_legitimate
FROM detections
WHERE id = ?
LIMIT 1
`
var d Detection
err := s.db.QueryRowContext(ctx, q, id).Scan(
&d.ID,
&d.RuleName,
&d.Severity,
&d.Hostname,
&d.Channel,
&d.EventID,
&d.Score,
&d.WindowStart,
&d.WindowEnd,
&d.Summary,
&d.Details,
&d.CreatedAt,
&d.Status,
&d.AnalystNote,
&d.ReviewedBy,
&d.ReviewedAt,
&d.IsFalsePositive,
&d.IsLegitimate,
)
return d, err
}
func (s *server) createSuppressionFromDetection(ctx context.Context, det Detection, reason, createdBy string, hours int) error {
var expiresAt any = nil
if hours > 0 {
expiresAt = time.Now().UTC().Add(time.Duration(hours) * time.Hour)
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO detection_suppressions
(rule_name, hostname, channel_name, event_id, reason, created_by, expires_at, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`,
det.RuleName,
det.Hostname,
det.Channel,
det.EventID,
reason,
createdBy,
expiresAt,
)
return err
}
func (s *server) listBaselineAnomalies(ctx context.Context, host, channel, severity string, eventID uint32, limit int) ([]BaselineAnomalyRow, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
query := `
SELECT id, severity, hostname, channel_name, event_id, score,
window_start, window_end, summary, details_json, created_at
FROM detections
WHERE rule_name = 'baseline_event_rate_anomaly'
`
args := make([]any, 0, 8)
if host != "" {
query += ` AND hostname = ?`
args = append(args, host)
}
if channel != "" {
query += ` AND channel_name = ?`
args = append(args, channel)
}
if eventID != 0 {
query += ` AND event_id = ?`
args = append(args, eventID)
}
if severity != "" {
query += ` AND severity = ?`
args = append(args, severity)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]BaselineAnomalyRow, 0)
for rows.Next() {
var row BaselineAnomalyRow
var detailsRaw []byte
if err := rows.Scan(
&row.ID,
&row.Severity,
&row.Hostname,
&row.Channel,
&row.EventID,
&row.Score,
&row.WindowStart,
&row.WindowEnd,
&row.Summary,
&detailsRaw,
&row.CreatedAt,
); err != nil {
return nil, err
}
var details baselineDetailsJSON
if err := json.Unmarshal(detailsRaw, &details); err == nil {
row.Count = details.Count
row.AvgCount = details.AvgCount
row.StddevCount = details.StddevCount
row.ZScore = details.ZScore
row.SampleCount = details.SampleCount
row.HourOfDay = details.HourOfDay
row.DayOfWeek = details.DayOfWeek
row.WindowMin = details.WindowMinutes
} else {
row.ZScore = row.Score
}
out = append(out, row)
}
return out, rows.Err()
}
func (s *server) handleUIBaseline(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
filters := map[string]string{
"host": strings.TrimSpace(r.URL.Query().Get("host")),
"channel": strings.TrimSpace(r.URL.Query().Get("channel")),
"event_id": strings.TrimSpace(r.URL.Query().Get("event_id")),
"severity": strings.TrimSpace(r.URL.Query().Get("severity")),
"limit": strings.TrimSpace(r.URL.Query().Get("limit")),
}
limit := 100
if filters["limit"] != "" {
if n, err := strconv.Atoi(filters["limit"]); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
var eventID uint32
if filters["event_id"] != "" {
if n, err := strconv.ParseUint(filters["event_id"], 10, 32); err == nil {
eventID = uint32(n)
}
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
items, err := s.listBaselineAnomalies(
ctx,
filters["host"],
filters["channel"],
filters["severity"],
eventID,
limit,
)
if err != nil {
s.logger.Printf("ui baseline: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
data := BaselinePageData{
Title: "Baseline-Anomalien",
Now: time.Now(),
Filters: filters,
Anomalies: items,
}
s.renderTemplate(w, "baseline", data)
}
func (s *server) listDynamicRules(ctx context.Context) ([]DynamicRule, error) {
const q = `
SELECT id,
name,
COALESCE(description, ''),
severity,
channel,
event_ids,
COALESCE(match_field, ''),
COALESCE(match_operator, ''),
COALESCE(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
FROM agents
ORDER BY hostname ASC
`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
now := time.Now().UTC()
out := make([]AgentRow, 0)
for rows.Next() {
var a AgentRow
if err := rows.Scan(
&a.ID,
&a.Hostname,
&a.FirstSeen,
&a.LastSeen,
&a.LastIP,
&a.IsEnabled,
); err != nil {
return nil, err
}
if !a.LastSeen.IsZero() {
a.OfflineMinutes = int(now.Sub(a.LastSeen.UTC()).Minutes())
a.IsOnline = a.IsEnabled && now.Sub(a.LastSeen.UTC()) <= s.cfg.OfflineAfter
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *server) handleUIAgents(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()
agents, err := s.listAgents(ctx)
if err != nil {
s.logger.Printf("ui agents: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
data := AgentListPageData{
Title: "Agents",
Now: time.Now(),
Agents: agents,
}
s.renderTemplate(w, "agents", data)
}
func (s *server) handleUIAgentToggle(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()
res, err := s.db.ExecContext(ctx, `
UPDATE agents
SET is_enabled = ?
WHERE id = ?
`, enabled, id)
if err != nil {
s.logger.Printf("toggle agent: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/ui/agents", http.StatusSeeOther)
}
func (s *server) handleUIIndex(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()
stats, err := s.getDashboardStats(ctx)
if err != nil {
s.logger.Printf("dashboard stats: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
dets, err := s.listDetections(ctx, "", "", "", "", 20)
if err != nil {
s.logger.Printf("dashboard detections: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
events, err := s.listEvents(ctx, EventFilter{Limit: 20})
if err != nil {
s.logger.Printf("dashboard events: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
data := DashboardPageData{
Title: "SIEM-lite Dashboard",
Now: time.Now(),
Stats: stats,
RecentDetections: dets,
RecentEvents: events,
}
s.renderTemplate(w, "dashboard", data)
}
func (s *server) handleUIDetections(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
filters := map[string]string{
"host": strings.TrimSpace(r.URL.Query().Get("host")),
"rule": strings.TrimSpace(r.URL.Query().Get("rule")),
"severity": strings.TrimSpace(r.URL.Query().Get("severity")),
"status": strings.TrimSpace(r.URL.Query().Get("status")),
"limit": strings.TrimSpace(r.URL.Query().Get("limit")),
}
limit := s.cfg.DetectionsLimit
if filters["limit"] != "" {
if n, err := strconv.Atoi(filters["limit"]); err == nil && n > 0 && n <= 500 {
limit = n
}
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
items, err := s.listDetections(ctx, filters["host"], filters["rule"], filters["severity"], filters["status"], limit)
if err != nil {
s.logger.Printf("ui detections: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
data := DetectionListPageData{
Title: "Detections",
Now: time.Now(),
Filters: filters,
Detections: items,
}
s.renderTemplate(w, "detections", data)
}
func (s *server) handleUIEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
filter := EventFilter{
Host: strings.TrimSpace(r.URL.Query().Get("host")),
Channel: strings.TrimSpace(r.URL.Query().Get("channel")),
Rule: strings.TrimSpace(r.URL.Query().Get("rule")),
User: strings.TrimSpace(r.URL.Query().Get("user")),
SrcIP: strings.TrimSpace(r.URL.Query().Get("src_ip")),
Severity: strings.TrimSpace(r.URL.Query().Get("severity")),
TimeFrom: strings.TrimSpace(r.URL.Query().Get("from")),
TimeTo: strings.TrimSpace(r.URL.Query().Get("to")),
Limit: 100,
}
if v := strings.TrimSpace(r.URL.Query().Get("event_id")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
filter.EventID = uint32(n)
}
}
if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
filter.Limit = n
}
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
events, err := s.listEvents(ctx, filter)
if err != nil {
s.logger.Printf("ui events: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
filters := map[string]string{
"host": filter.Host,
"channel": filter.Channel,
"rule": filter.Rule,
"user": filter.User,
"src_ip": filter.SrcIP,
"severity": filter.Severity,
"from": filter.TimeFrom,
"to": filter.TimeTo,
"limit": strconv.Itoa(filter.Limit),
"event_id": func() string {
if filter.EventID == 0 {
return ""
}
return strconv.Itoa(int(filter.EventID))
}(),
}
data := EventListPageData{
Title: "Events",
Now: time.Now(),
Filters: filters,
Events: events,
}
s.renderTemplate(w, "events", data)
}
func (s *server) handleUIEventDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
idStr := strings.TrimSpace(r.URL.Query().Get("id"))
if idStr == "" {
writeError(w, http.StatusBadRequest, "missing id")
return
}
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
ev, err := s.getEventByID(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r)
return
}
s.logger.Printf("ui event detail: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
data := EventDetailPageData{
Title: "Event Detail",
Now: time.Now(),
Event: ev,
}
s.renderTemplate(w, "event_detail", data)
}
func (s *server) renderTemplate(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("render template %s: %v", name, err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
type EventFilter struct {
Host string
Channel string
Rule string
User string
SrcIP string
Severity string
TimeFrom string
TimeTo string
EventID uint32
Limit int
}
func (s *server) getDashboardStats(ctx context.Context) (DashboardStats, error) {
var stats DashboardStats
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agents WHERE is_enabled = 1`).Scan(&stats.AgentsTotal); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agents WHERE is_enabled = 1 AND last_seen >= ?`, time.Now().UTC().Add(-s.cfg.OfflineAfter)).Scan(&stats.AgentsActive); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM event_logs WHERE ts >= ?`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.Events24h); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM detections WHERE created_at >= ?`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.Detections24h); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM detections WHERE created_at >= ? AND severity = 'high'`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.HighDetections24h); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM detections WHERE status = 'open'
`).Scan(&stats.OpenDetections); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM detections WHERE status = 'investigating'
`).Scan(&stats.InvestigatingDetections); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM detections
WHERE created_at >= ? AND severity = 'critical'
`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.CriticalDetections24h); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM detections
WHERE created_at >= ? AND status = 'false_positive'
`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.FalsePositive24h); err != nil {
return stats, err
}
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM detections
WHERE created_at >= ? AND status = 'legitimate'
`, time.Now().UTC().Add(-24*time.Hour)).Scan(&stats.Legitimate24h); err != nil {
return stats, err
}
return stats, nil
}
func (s *server) listEvents(ctx context.Context, f EventFilter) ([]EventRow, error) {
if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 100
}
query := `
SELECT id, hostname, channel_name, event_id, source, computer, provider_name,
target_user, target_domain, subject_user, subject_domain,
workstation, src_ip, src_port, logon_type, process_name,
authentication_package, logon_process, status_text, sub_status_text,
failure_reason, ts, received_at, msg
FROM event_logs
WHERE 1=1
`
args := make([]any, 0, 16)
if f.Host != "" {
query += ` AND hostname = ?`
args = append(args, f.Host)
}
if f.Channel != "" {
query += ` AND channel_name = ?`
args = append(args, f.Channel)
}
if f.EventID != 0 {
query += ` AND event_id = ?`
args = append(args, f.EventID)
}
if f.User != "" {
query += ` AND (target_user = ? OR subject_user = ?)`
args = append(args, f.User, f.User)
}
if f.SrcIP != "" {
query += ` AND src_ip = ?`
args = append(args, f.SrcIP)
}
if f.TimeFrom != "" {
if t, err := parseUIRFC3339(f.TimeFrom); err == nil {
query += ` AND ts >= ?`
args = append(args, t)
}
}
if f.TimeTo != "" {
if t, err := parseUIRFC3339(f.TimeTo); err == nil {
query += ` AND ts <= ?`
args = append(args, t)
}
}
if f.Rule != "" || f.Severity != "" {
query += ` AND EXISTS (
SELECT 1
FROM detections d
WHERE d.hostname = event_logs.hostname
AND event_logs.ts >= d.window_start
AND event_logs.ts <= d.window_end
`
if f.Rule != "" {
query += ` AND d.rule_name = ?`
args = append(args, f.Rule)
}
if f.Severity != "" {
query += ` AND d.severity = ?`
args = append(args, f.Severity)
}
query += ` )`
}
query += ` ORDER BY ts DESC LIMIT ?`
args = append(args, f.Limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EventRow
for rows.Next() {
var ev EventRow
if err := rows.Scan(
&ev.ID, &ev.Hostname, &ev.Channel, &ev.EventID, &ev.Source,
&ev.Computer, &ev.ProviderName,
&ev.TargetUser, &ev.TargetDomain, &ev.SubjectUser, &ev.SubjectDomain,
&ev.Workstation, &ev.SrcIP, &ev.SrcPort, &ev.LogonType, &ev.ProcessName,
&ev.AuthenticationPackage, &ev.LogonProcess, &ev.StatusText, &ev.SubStatusText,
&ev.FailureReason, &ev.Time, &ev.ReceivedAt, &ev.Message,
); err != nil {
return nil, err
}
out = append(out, ev)
}
return out, rows.Err()
}
func (s *server) getEventByID(ctx context.Context, id uint64) (EventRow, error) {
const q = `
SELECT id, hostname, channel_name, event_id, source, computer, provider_name,
target_user, target_domain, subject_user, subject_domain,
workstation, src_ip, src_port, logon_type, process_name,
authentication_package, logon_process, status_text, sub_status_text,
failure_reason, ts, received_at, msg
FROM event_logs
WHERE id = ?
LIMIT 1
`
var ev EventRow
err := s.db.QueryRowContext(ctx, q, id).Scan(
&ev.ID, &ev.Hostname, &ev.Channel, &ev.EventID, &ev.Source,
&ev.Computer, &ev.ProviderName,
&ev.TargetUser, &ev.TargetDomain, &ev.SubjectUser, &ev.SubjectDomain,
&ev.Workstation, &ev.SrcIP, &ev.SrcPort, &ev.LogonType, &ev.ProcessName,
&ev.AuthenticationPackage, &ev.LogonProcess, &ev.StatusText, &ev.SubStatusText,
&ev.FailureReason, &ev.Time, &ev.ReceivedAt, &ev.Message,
)
return ev, err
}
func parseUIRFC3339(v string) (time.Time, error) {
return time.Parse(time.RFC3339, strings.TrimSpace(v))
}
func loadConfig() Config {
return Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DBDSN: mustGetenv("DB_DSN"),
MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 10*1024*1024),
HTTPReadTimeout: getenvDuration("HTTP_READ_TIMEOUT", 15*time.Second),
HTTPWriteTimeout: getenvDuration("HTTP_WRITE_TIMEOUT", 30*time.Second),
HTTPIdleTimeout: getenvDuration("HTTP_IDLE_TIMEOUT", 60*time.Second),
DBMaxOpenConns: getenvInt("DB_MAX_OPEN_CONNS", 50),
DBMaxIdleConns: getenvInt("DB_MAX_IDLE_CONNS", 25),
DBConnMaxLifetime: getenvDuration("DB_CONN_MAX_LIFETIME", 3*time.Minute),
DBConnMaxIdleTime: getenvDuration("DB_CONN_MAX_IDLE_TIME", 1*time.Minute),
DetectionInterval: getenvDuration("DETECTION_INTERVAL", 1*time.Minute),
OfflineAfter: getenvDuration("OFFLINE_AFTER", 10*time.Minute),
OfflineAlertMax: getenvDuration("OFFLINE_ALERT_MAX", 120*time.Minute),
FailedLogonWindow: getenvDuration("FAILED_LOGON_WINDOW", 5*time.Minute),
FailedLogonThreshold: getenvInt("FAILED_LOGON_THRESHOLD", 25),
RebootWindow: getenvDuration("REBOOT_WINDOW", 15*time.Minute),
RebootThreshold: getenvInt("REBOOT_THRESHOLD", 3),
PasswordSprayWindow: getenvDuration("PASSWORD_SPRAY_WINDOW", 5*time.Minute),
PasswordSprayMinUsers: getenvInt("PASSWORD_SPRAY_MIN_USERS", 5),
PasswordSprayMinAttempts: getenvInt("PASSWORD_SPRAY_MIN_ATTEMPTS", 15),
SuccessAfterFailureWindow: getenvDuration("SUCCESS_AFTER_FAILURE_WINDOW", 10*time.Minute),
NewSourceIPLookback: getenvDuration("NEW_SOURCE_IP_LOOKBACK", 30*24*time.Hour),
NewSourceIPWindow: getenvDuration("NEW_SOURCE_IP_WINDOW", 10*time.Minute),
DetectionsLimit: getenvInt("DETECTIONS_LIMIT", 100),
EnrollmentKey: mustGetenv("ENROLLMENT_KEY"),
BaselineEnabled: getenvBool("BASELINE_ENABLED", true),
BaselineWindow: getenvDuration("BASELINE_WINDOW", 5*time.Minute),
BaselineMinSamples: getenvInt("BASELINE_MIN_SAMPLES", 24),
BaselineMinCount: getenvInt("BASELINE_MIN_COUNT", 10),
BaselineMediumZScore: getenvFloat("BASELINE_MEDIUM_Z", 2.5),
BaselineHighZScore: getenvFloat("BASELINE_HIGH_Z", 4.0),
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),
}
}
func getenvBool(key string, def bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
switch strings.ToLower(v) {
case "1", "true", "yes", "y", "on":
return true
case "0", "false", "no", "n", "off":
return false
default:
log.Fatalf("invalid bool for %s: %s", key, v)
return def
}
}
func getenvFloat(key string, def float64) float64 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
log.Fatalf("invalid float for %s: %v", key, err)
}
return f
}
func (s *server) handleHealthz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
ingestRejectedTotal.WithLabelValues("method_not_allowed").Inc()
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"uptime_sec": int(time.Since(s.startTime).Seconds()),
})
}
func (s *server) handleReadyz(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(), 3*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "database not ready")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (s *server) handleIngest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
ingestRejectedTotal.WithLabelValues("method_not_allowed").Inc()
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
apiKey := strings.TrimSpace(r.Header.Get("X-API-Key"))
enrollmentKey := strings.TrimSpace(r.Header.Get("X-Enrollment-Key"))
if apiKey == "" {
ingestRejectedTotal.WithLabelValues("missing_api_key").Inc()
writeError(w, http.StatusUnauthorized, "missing api key")
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes)
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var batch []LogPayload
if err := dec.Decode(&batch); err != nil {
ingestRejectedTotal.WithLabelValues("invalid_json").Inc()
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if len(batch) == 0 {
ingestRejectedTotal.WithLabelValues("empty_batch").Inc()
writeError(w, http.StatusBadRequest, "empty batch")
return
}
if len(batch) > 1000 {
ingestRejectedTotal.WithLabelValues("batch_too_large").Inc()
writeError(w, http.StatusBadRequest, "batch too large")
return
}
for i := range batch {
if err := validatePayload(&batch[i]); err != nil {
ingestRejectedTotal.WithLabelValues("invalid_payload").Inc()
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid payload at index %d: %v", i, err))
return
}
}
hostname := batch[0].Hostname
for i := 1; i < len(batch); i++ {
if batch[i].Hostname != hostname {
ingestRejectedTotal.WithLabelValues("mixed_hostnames").Inc()
writeError(w, http.StatusBadRequest, "all events in a batch must use the same hostname")
return
}
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
agentID, err := s.authenticateTouchOrEnrollAgent(
ctx,
hostname,
apiKey,
enrollmentKey,
clientIP(r.RemoteAddr),
)
if err != nil {
if errors.Is(err, errUnauthorized) {
ingestRejectedTotal.WithLabelValues("unauthorized").Inc()
writeError(w, http.StatusUnauthorized, "invalid api key or hostname")
return
}
ingestRejectedTotal.WithLabelValues("auth_error").Inc()
s.logger.Printf("authenticate agent: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if err := s.insertBatch(ctx, agentID, batch); err != nil {
dbInsertFailuresTotal.Inc()
s.logger.Printf("insert batch: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
ingestBatchesTotal.Inc()
for _, item := range batch {
ingestEventsTotal.WithLabelValues(item.Channel, strconv.FormatUint(uint64(item.EventID), 10)).Inc()
}
writeJSON(w, http.StatusAccepted, ingestResponse{Accepted: len(batch)})
}
func (s *server) handleDetections(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
limit := s.cfg.DetectionsLimit
if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
limit = n
}
}
host := strings.TrimSpace(r.URL.Query().Get("host"))
rule := strings.TrimSpace(r.URL.Query().Get("rule"))
severity := strings.TrimSpace(r.URL.Query().Get("severity"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
items, err := s.listDetections(ctx, host, rule, severity, status, limit)
if err != nil {
s.logger.Printf("list detections: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *server) authenticateTouchOrEnrollAgent(ctx context.Context, hostname, apiKey, enrollmentKey, remoteIP string) (uint64, error) {
const q = `
SELECT id, api_key_hash, is_enabled
FROM agents
WHERE hostname = ?
LIMIT 1
`
var id uint64
var storedHash string
var enabled bool
err := s.db.QueryRowContext(ctx, q, hostname).Scan(&id, &storedHash, &enabled)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if enrollmentKey == "" {
return 0, errUnauthorized
}
if !constantTimeEqual(sha256Hex(enrollmentKey), sha256Hex(s.cfg.EnrollmentKey)) {
return 0, errUnauthorized
}
return s.enrollNewAgent(ctx, hostname, apiKey, remoteIP)
}
return 0, err
}
if !enabled {
return 0, errUnauthorized
}
if !constantTimeEqual(strings.ToLower(storedHash), sha256Hex(apiKey)) {
return 0, errUnauthorized
}
const upd = `
UPDATE agents
SET last_seen = UTC_TIMESTAMP(6), last_ip = ?
WHERE id = ?
`
if _, err := s.db.ExecContext(ctx, upd, remoteIP, id); err != nil {
return 0, err
}
return id, nil
}
func (s *server) enrollNewAgent(ctx context.Context, hostname, apiKey, remoteIP string) (uint64, error) {
if strings.TrimSpace(hostname) == "" {
return 0, errUnauthorized
}
if strings.TrimSpace(apiKey) == "" {
return 0, errUnauthorized
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
const ins = `
INSERT INTO agents (hostname, api_key_hash, first_seen, last_seen, last_ip, is_enabled)
VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?, 1)
`
res, err := tx.ExecContext(ctx, ins, hostname, sha256Hex(apiKey), remoteIP)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
s.logger.Printf("agent auto-enrolled: host=%s ip=%s", hostname, remoteIP)
return uint64(id), nil
}
var errUnauthorized = errors.New("unauthorized")
func (s *server) insertBatch(ctx context.Context, agentID uint64, batch []LogPayload) error {
start := time.Now()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var sb strings.Builder
args := make([]any, 0, len(batch)*28)
sb.WriteString(`
INSERT INTO event_logs (
agent_id, hostname, channel_name, event_id, source, computer, provider_name,
level_value, task_value, opcode_value, keywords,
target_user, target_domain, subject_user, subject_domain,
workstation, src_ip, src_port, logon_type, process_name,
authentication_package, logon_process, status_text, sub_status_text,
failure_reason, ts, msg, msg_sha256
) VALUES
`)
for i, item := range batch {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
norm := NormalizeEventXML(item.Message)
args = append(args,
agentID,
item.Hostname,
item.Channel,
item.EventID,
item.Source,
norm.Computer,
firstNonEmpty(norm.ProviderName, item.Source),
norm.LevelValue,
norm.TaskValue,
norm.OpcodeValue,
norm.Keywords,
norm.TargetUser,
norm.TargetDomain,
norm.SubjectUser,
norm.SubjectDomain,
norm.Workstation,
norm.SrcIP,
norm.SrcPort,
norm.LogonType,
norm.ProcessName,
norm.AuthenticationPackage,
norm.LogonProcess,
norm.StatusText,
norm.SubStatusText,
norm.FailureReason,
item.Time.UTC(),
item.Message,
sha256Hex(item.Message),
)
}
if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
dbBatchSizeHist.Observe(float64(len(batch)))
dbInsertEventsTotal.Add(float64(len(batch)))
dbTxDurationHist.Observe(time.Since(start).Seconds())
return nil
}
func (s *server) listDetections(ctx context.Context, host, rule, severity, status string, limit int) ([]Detection, error) {
base := `
SELECT id, rule_name, severity, hostname, channel_name, event_id, score,
window_start, window_end, summary, details_json, created_at,
status,
COALESCE(analyst_note, ''),
COALESCE(reviewed_by, ''),
reviewed_at,
is_false_positive,
is_legitimate
FROM detections
WHERE 1=1
`
args := make([]any, 0, 4)
if host != "" {
base += " AND hostname = ?"
args = append(args, host)
}
if rule != "" {
base += " AND rule_name = ?"
args = append(args, rule)
}
if severity != "" {
base += " AND severity = ?"
args = append(args, severity)
}
if status != "" {
base += " AND status = ?"
args = append(args, status)
}
base += " ORDER BY created_at DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, base, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Detection
for rows.Next() {
var d Detection
if err := rows.Scan(
&d.ID,
&d.RuleName,
&d.Severity,
&d.Hostname,
&d.Channel,
&d.EventID,
&d.Score,
&d.WindowStart,
&d.WindowEnd,
&d.Summary,
&d.Details,
&d.CreatedAt,
&d.Status,
&d.AnalystNote,
&d.ReviewedBy,
&d.ReviewedAt,
&d.IsFalsePositive,
&d.IsLegitimate,
); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
func (s *server) runDetectionLoop() {
ticker := time.NewTicker(s.cfg.DetectionInterval)
defer ticker.Stop()
s.runDetectionsOnce()
for range ticker.C {
s.runDetectionsOnce()
}
}
func (d *detector) runBaselineUpdate(ctx context.Context) error {
if !d.cfg.BaselineEnabled {
return nil
}
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.BaselineWindow)
rows, err := d.db.QueryContext(ctx, `
SELECT
hostname,
channel_name,
event_id,
HOUR(ts) AS hour_of_day,
WEEKDAY(ts) AS day_of_week,
COUNT(*) AS cnt
FROM event_logs
WHERE ts >= ? AND ts < ?
GROUP BY hostname, channel_name, event_id, HOUR(ts), WEEKDAY(ts)
`, windowStart, windowEnd)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var b BaselineBucket
if err := rows.Scan(
&b.Hostname,
&b.Channel,
&b.EventID,
&b.Hour,
&b.DayOfWeek,
&b.Count,
); err != nil {
return err
}
excluded, err := d.isBaselineExcluded(ctx, b.Hostname, b.Channel, b.EventID)
if err != nil {
return err
}
if excluded {
continue
}
incident, err := d.hasConfirmedIncidentInWindow(ctx, b.Hostname, b.Channel, b.EventID, windowStart, windowEnd)
if err != nil {
return err
}
if incident {
continue
}
if err := d.updateBaselineBucket(ctx, b); err != nil {
return err
}
}
return rows.Err()
}
func (d *detector) hasConfirmedIncidentInWindow(ctx context.Context, hostname, channel string, eventID uint32, windowStart, windowEnd time.Time) (bool, error) {
var count int
err := d.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM detections
WHERE status = 'confirmed_incident'
AND hostname = ?
AND channel_name = ?
AND event_id = ?
AND window_start < ?
AND window_end > ?
`,
hostname,
channel,
eventID,
windowEnd,
windowStart,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (d *detector) updateBaselineBucket(ctx context.Context, b BaselineBucket) error {
tx, err := d.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var stat BaselineStat
err = tx.QueryRowContext(ctx, `
SELECT avg_count, m2_count, stddev_count, sample_count
FROM baseline_event_stats
WHERE hostname = ?
AND channel_name = ?
AND event_id = ?
AND hour_of_day = ?
AND day_of_week = ?
FOR UPDATE
`,
b.Hostname,
b.Channel,
b.EventID,
b.Hour,
b.DayOfWeek,
).Scan(
&stat.AvgCount,
&stat.M2Count,
&stat.StddevCount,
&stat.SampleCount,
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
x := float64(b.Count)
if errors.Is(err, sql.ErrNoRows) {
_, err := tx.ExecContext(ctx, `
INSERT INTO baseline_event_stats
(hostname, channel_name, event_id, hour_of_day, day_of_week,
avg_count, m2_count, stddev_count, sample_count)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 1)
`,
b.Hostname,
b.Channel,
b.EventID,
b.Hour,
b.DayOfWeek,
x,
)
if err != nil {
return err
}
return tx.Commit()
}
newSamples := stat.SampleCount + 1
delta := x - stat.AvgCount
newAvg := stat.AvgCount + delta/float64(newSamples)
delta2 := x - newAvg
newM2 := stat.M2Count + delta*delta2
newStddev := 0.0
if newSamples > 1 {
newStddev = math.Sqrt(newM2 / float64(newSamples-1))
}
_, err = tx.ExecContext(ctx, `
UPDATE baseline_event_stats
SET avg_count = ?,
m2_count = ?,
stddev_count = ?,
sample_count = ?,
last_updated = UTC_TIMESTAMP(6)
WHERE hostname = ?
AND channel_name = ?
AND event_id = ?
AND hour_of_day = ?
AND day_of_week = ?
`,
newAvg,
newM2,
newStddev,
newSamples,
b.Hostname,
b.Channel,
b.EventID,
b.Hour,
b.DayOfWeek,
)
if err != nil {
return err
}
return tx.Commit()
}
func (d *detector) runBaselineAnomalyRule(ctx context.Context) error {
if !d.cfg.BaselineEnabled {
return nil
}
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.BaselineWindow)
rows, err := d.db.QueryContext(ctx, `
SELECT
e.hostname,
e.channel_name,
e.event_id,
HOUR(e.ts) AS hour_of_day,
WEEKDAY(e.ts) AS day_of_week,
COUNT(*) AS cnt,
b.avg_count,
b.stddev_count,
b.sample_count
FROM event_logs e
JOIN baseline_event_stats b
ON b.hostname = e.hostname
AND b.channel_name = e.channel_name
AND b.event_id = e.event_id
AND b.hour_of_day = HOUR(e.ts)
AND b.day_of_week = WEEKDAY(e.ts)
WHERE e.ts >= ? AND e.ts < ?
GROUP BY
e.hostname,
e.channel_name,
e.event_id,
HOUR(e.ts),
WEEKDAY(e.ts),
b.avg_count,
b.stddev_count,
b.sample_count
`, windowStart, windowEnd)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host string
var channel string
var eventID uint32
var hour int
var dayOfWeek int
var count int
var avg float64
var stddev float64
var samples int
if err := rows.Scan(
&host,
&channel,
&eventID,
&hour,
&dayOfWeek,
&count,
&avg,
&stddev,
&samples,
); err != nil {
return err
}
eventIDStr := strconv.Itoa(int(eventID))
d.baselineCurrentCountGauge.WithLabelValues(host, channel, eventIDStr).Set(float64(count))
d.baselineAverageGauge.WithLabelValues(host, channel, eventIDStr).Set(avg)
d.baselineStddevGauge.WithLabelValues(host, channel, eventIDStr).Set(stddev)
d.baselineSamplesGauge.WithLabelValues(host, channel, eventIDStr).Set(float64(samples))
if samples < d.cfg.BaselineMinSamples {
continue
}
if count < d.cfg.BaselineMinCount {
continue
}
if stddev <= 0 {
continue
}
z := (float64(count) - avg) / stddev
if z < d.cfg.BaselineMediumZScore {
continue
}
severity := "medium"
if z >= d.cfg.BaselineHighZScore {
severity = "high"
}
suppressed, err := d.isBaselineSuppressed(ctx, host, channel, eventID, windowEnd)
if err != nil {
return err
}
if suppressed {
continue
}
score := z
created, err := d.insertDetection(ctx, Detection{
RuleName: "baseline_event_rate_anomaly",
Severity: severity,
Hostname: host,
Channel: channel,
EventID: eventID,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf(
"Baseline-Anomalie auf %s: %s EventID %d kam %d-mal in %d Minuten, normal %.2f ± %.2f, z=%.2f",
host,
channel,
eventID,
count,
int(d.cfg.BaselineWindow.Minutes()),
avg,
stddev,
z,
),
Details: mustJSON(map[string]any{
"hostname": host,
"channel": channel,
"event_id": eventID,
"count": count,
"avg_count": avg,
"stddev_count": stddev,
"z_score": z,
"sample_count": samples,
"hour_of_day": hour,
"day_of_week": dayOfWeek,
"window_minutes": int(d.cfg.BaselineWindow.Minutes()),
"min_samples": d.cfg.BaselineMinSamples,
"medium_z": d.cfg.BaselineMediumZScore,
"high_z": d.cfg.BaselineHighZScore,
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("baseline_event_rate_anomaly", severity).Inc()
d.anomalyScoreGauge.WithLabelValues(host, "baseline_event_rate_anomaly").Set(score)
}
}
return rows.Err()
}
func (d *detector) isBaselineSuppressed(ctx context.Context, hostname, channel string, eventID uint32, now time.Time) (bool, error) {
if d.cfg.BaselineSuppressFor <= 0 {
return false, nil
}
since := now.UTC().Add(-d.cfg.BaselineSuppressFor)
var count int
err := d.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM detections
WHERE rule_name = 'baseline_event_rate_anomaly'
AND hostname = ?
AND channel_name = ?
AND event_id = ?
AND created_at >= ?
`,
hostname,
channel,
eventID,
since,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
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()
if err := s.detector.updateAgentMetrics(ctx); err != nil {
s.logger.Printf("update agent metrics: %v", err)
}
rules := []struct {
name string
fn func(context.Context) error
}{
{"agent_offline", s.detector.runAgentOfflineRule},
{"failed_logon_spike", s.detector.runFailedLogonSpikeRule},
{"reboot_spike", s.detector.runRebootSpikeRule},
{"new_event_id", s.detector.runNewEventIDRule},
{"password_spray", s.detector.runPasswordSprayRule},
{"success_after_failures", s.detector.runSuccessAfterFailuresRule},
{"new_source_ip_for_user", s.detector.runNewSourceIPForUserRule},
{"dynamic_rules", s.detector.runDynamicRules},
{"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 {
start := time.Now()
if err := rule.fn(ctx); err != nil {
s.logger.Printf("rule %s error: %v", rule.name, err)
s.detector.ruleErrorsTotal.WithLabelValues(rule.name).Inc()
continue
}
s.detector.ruleLastRunGauge.WithLabelValues(rule.name).Set(float64(time.Now().Unix()))
s.detector.ruleRuntimeHist.WithLabelValues(rule.name).Observe(time.Since(start).Seconds())
}
}
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 25
case "high":
return 10
case "medium":
return 2
case "low":
return 0.3
case "info":
return 0.05
default:
return 0.2
}
}
func severityFromRisk(score float64) string {
switch {
case score >= 120:
return "critical"
case score >= 60:
return "high"
case score >= 20:
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)
if _, err := d.db.ExecContext(ctx, `
UPDATE host_risk_scores
SET risk_score = 0,
severity = 'info',
open_detections = 0,
high_detections = 0,
critical_detections = 0,
confirmed_incidents = 0,
updated_at = UTC_TIMESTAMP(6)
`); err != nil {
return err
}
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', 'legitimate', 'resolved', 'plausible')
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
}
a := stats[host]
if a == nil {
a = &agg{}
stats[host] = a
}
w := riskWeight(sev)
switch status {
case "confirmed_incident":
w += 75
a.confirmedIncidents += count
case "investigating":
w *= 2
a.open += count
case "acknowledged":
w *= 0.5
a.open += count
case "open":
w *= 0.35
a.open += count
default:
a.open += count
}
if sev == "high" {
a.high += count
}
if sev == "critical" {
a.critical += count
}
// Dämpfung: 100 gleiche offene Events sollen nicht 100x hart zählen.
a.score += w * math.Sqrt(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
}
if d.hostRiskScoreGauge != nil {
d.hostRiskScoreGauge.WithLabelValues(host, sev).Set(a.score)
}
}
return nil
}
func (d *detector) updateAgentMetrics(ctx context.Context) error {
const q = `
SELECT hostname, last_seen
FROM agents
WHERE is_enabled = 1
`
rows, err := d.db.QueryContext(ctx, q)
if err != nil {
return err
}
defer rows.Close()
active := 0
now := time.Now().UTC()
for rows.Next() {
var host string
var lastSeen time.Time
if err := rows.Scan(&host, &lastSeen); err != nil {
return err
}
lastSeenUTC := lastSeen.UTC()
d.lastSeenGauge.WithLabelValues(host).Set(float64(lastSeenUTC.Unix()))
if now.Sub(lastSeenUTC) <= d.cfg.OfflineAfter {
active++
}
}
if err := rows.Err(); err != nil {
return err
}
d.activeAgentsGauge.Set(float64(active))
return nil
}
func (d *detector) runAgentOfflineRule(ctx context.Context) error {
windowEnd := time.Now().UTC()
offlineAfterTime := windowEnd.Add(-d.cfg.OfflineAfter)
maxOfflineTime := windowEnd.Add(-d.cfg.OfflineAlertMax)
const q = `
SELECT hostname, last_seen
FROM agents
WHERE is_enabled = 1
AND last_seen < ?
AND last_seen >= ?
`
rows, err := d.db.QueryContext(ctx, q, offlineAfterTime, maxOfflineTime)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host string
var lastSeen time.Time
if err := rows.Scan(&host, &lastSeen); err != nil {
return err
}
minutes := int(windowEnd.Sub(lastSeen.UTC()).Minutes())
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)
created, err := d.insertDetection(ctx, Detection{
RuleName: "agent_offline",
Severity: severity,
Hostname: host,
Score: score,
WindowStart: offlineAfterTime,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Agent %s ist seit %d Minuten offline", host, minutes),
Details: mustJSON(map[string]any{
"last_seen": lastSeen.UTC().Format(time.RFC3339Nano),
"offline_minutes": minutes,
"offline_after_min": int(d.cfg.OfflineAfter.Minutes()),
"offline_alert_max_min": int(d.cfg.OfflineAlertMax.Minutes()),
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("agent_offline", severity).Inc()
}
}
return rows.Err()
}
func (d *detector) runFailedLogonSpikeRule(ctx context.Context) error {
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.FailedLogonWindow)
const q = `
SELECT hostname, COUNT(*) AS cnt
FROM event_logs
WHERE channel_name = 'Security'
AND event_id = 4625
AND ts >= ? AND ts < ?
GROUP BY hostname
HAVING COUNT(*) >= ?
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, d.cfg.FailedLogonThreshold)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host string
var count int
if err := rows.Scan(&host, &count); err != nil {
return err
}
score := float64(count) / float64(d.cfg.FailedLogonThreshold)
severity := severityFromScore(score, 1.0, 2.0, 4.0, 8.0)
d.anomalyScoreGauge.WithLabelValues(host, "failed_logon_spike").Set(score)
created, err := d.insertDetection(ctx, Detection{
RuleName: "failed_logon_spike",
Severity: severity,
Hostname: host,
Channel: "Security",
EventID: 4625,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Host %s hatte %d fehlgeschlagene Logons in %d Minuten", host, count, int(d.cfg.FailedLogonWindow.Minutes())),
Details: mustJSON(map[string]any{
"count": count,
"threshold": d.cfg.FailedLogonThreshold,
"window_minutes": int(d.cfg.FailedLogonWindow.Minutes()),
"event_id": 4625,
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("failed_logon_spike", severity).Inc()
}
}
return rows.Err()
}
func (d *detector) runRebootSpikeRule(ctx context.Context) error {
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.RebootWindow)
const q = `
SELECT hostname, COUNT(*) AS cnt
FROM event_logs
WHERE channel_name = 'System'
AND event_id IN (1074, 6005, 6006)
AND ts >= ? AND ts < ?
GROUP BY hostname
HAVING COUNT(*) >= ?
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, d.cfg.RebootThreshold)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host string
var count int
if err := rows.Scan(&host, &count); err != nil {
return err
}
score := float64(count) / float64(d.cfg.RebootThreshold)
severity := severityFromScore(score, 1.0, 2.0, 4.0, 8.0)
d.anomalyScoreGauge.WithLabelValues(host, "reboot_spike").Set(score)
created, err := d.insertDetection(ctx, Detection{
RuleName: "reboot_spike",
Severity: severity,
Hostname: host,
Channel: "System",
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Host %s hatte %d Reboot-/Shutdown-Events in %d Minuten", host, count, int(d.cfg.RebootWindow.Minutes())),
Details: mustJSON(map[string]any{
"count": count,
"threshold": d.cfg.RebootThreshold,
"window_minutes": int(d.cfg.RebootWindow.Minutes()),
"event_ids": []int{1074, 6005, 6006},
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("reboot_spike", severity).Inc()
}
}
return rows.Err()
}
func (d *detector) runNewEventIDRule(ctx context.Context) error {
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.DetectionInterval)
const q = `
SELECT e.hostname, e.channel_name, e.event_id, COUNT(*) AS cnt
FROM event_logs e
WHERE e.ts >= ? AND e.ts < ?
AND NOT EXISTS (
SELECT 1
FROM event_logs old
WHERE old.hostname = e.hostname
AND old.channel_name = e.channel_name
AND old.event_id = e.event_id
AND old.ts < ?
)
GROUP BY e.hostname, e.channel_name, e.event_id
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, windowStart)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, channel string
var eventID uint32
var count int
if err := rows.Scan(&host, &channel, &eventID, &count); err != nil {
return err
}
score := 1.0 + math.Log10(float64(count)+1)
severity := "medium"
if count >= 10 {
severity = "high"
}
d.anomalyScoreGauge.WithLabelValues(host, "new_event_id").Set(score)
created, err := d.insertDetection(ctx, Detection{
RuleName: "new_event_id",
Severity: severity,
Hostname: host,
Channel: channel,
EventID: eventID,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Host %s sendet erstmals Event-ID %d im Channel %s", host, eventID, channel),
Details: mustJSON(map[string]any{
"count": count,
"channel": channel,
"event_id": eventID,
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("new_event_id", severity).Inc()
}
}
return rows.Err()
}
func (d *detector) runPasswordSprayRule(ctx context.Context) error {
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.PasswordSprayWindow)
const q = `
SELECT hostname, src_ip, COUNT(*) AS attempts, COUNT(DISTINCT target_user) AS users
FROM event_logs
WHERE channel_name = 'Security'
AND event_id = 4625
AND ts >= ? AND ts < ?
AND src_ip <> '' AND src_ip <> '-' AND src_ip <> '::1' AND src_ip <> '127.0.0.1'
AND target_user <> '' AND target_user <> '-'
GROUP BY hostname, src_ip
HAVING COUNT(*) >= ? AND COUNT(DISTINCT target_user) >= ?
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, d.cfg.PasswordSprayMinAttempts, d.cfg.PasswordSprayMinUsers)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, srcIP string
var attempts, users int
if err := rows.Scan(&host, &srcIP, &attempts, &users); err != nil {
return err
}
score := math.Max(float64(attempts)/float64(d.cfg.PasswordSprayMinAttempts), float64(users)/float64(d.cfg.PasswordSprayMinUsers))
severity := severityFromScore(score, 1.0, 2.0, 4.0, 8.0)
d.anomalyScoreGauge.WithLabelValues(host, "password_spray").Set(score)
created, err := d.insertDetection(ctx, Detection{
RuleName: "password_spray",
Severity: severity,
Hostname: host,
Channel: "Security",
EventID: 4625,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Möglicher Password-Spray auf %s von %s: %d Fehlversuche gegen %d Benutzer", host, srcIP, attempts, users),
Details: mustJSON(map[string]any{
"src_ip": srcIP,
"attempts": attempts,
"distinct_users": users,
"window_minutes": int(d.cfg.PasswordSprayWindow.Minutes()),
"event_id": 4625,
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("password_spray", severity).Inc()
}
}
return rows.Err()
}
func (d *detector) runSuccessAfterFailuresRule(ctx context.Context) error {
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.SuccessAfterFailureWindow)
const q = `
SELECT s.hostname, s.target_user, s.src_ip, COUNT(*) AS success_count
FROM event_logs s
WHERE s.channel_name = 'Security'
AND s.event_id = 4624
AND s.ts >= ? AND s.ts < ?
AND s.target_user <> '' AND s.target_user <> '-'
AND EXISTS (
SELECT 1
FROM event_logs f
WHERE f.hostname = s.hostname
AND f.channel_name = 'Security'
AND f.event_id = 4625
AND f.target_user = s.target_user
AND (
(f.src_ip = s.src_ip AND s.src_ip <> '' AND s.src_ip <> '-')
OR (s.src_ip = '' OR s.src_ip = '-' OR f.src_ip = '' OR f.src_ip = '-')
)
AND f.ts >= DATE_SUB(s.ts, INTERVAL ? SECOND)
AND f.ts < s.ts
)
GROUP BY s.hostname, s.target_user, s.src_ip
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, int(d.cfg.SuccessAfterFailureWindow.Seconds()))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, user, srcIP string
var successCount int
if err := rows.Scan(&host, &user, &srcIP, &successCount); err != nil {
return err
}
score := 2.0 + math.Log10(float64(successCount)+1)
severity := "high"
d.anomalyScoreGauge.WithLabelValues(host, "success_after_failures").Set(score)
created, err := d.insertDetection(ctx, Detection{
RuleName: "success_after_failures",
Severity: severity,
Hostname: host,
Channel: "Security",
EventID: 4624,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Erfolgreicher Logon nach Fehlversuchen auf %s für Benutzer %s", host, user),
Details: mustJSON(map[string]any{
"user": user,
"src_ip": srcIP,
"success_count": successCount,
"window_minutes": int(d.cfg.SuccessAfterFailureWindow.Minutes()),
"success_event": 4624,
"failure_event": 4625,
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("success_after_failures", severity).Inc()
}
}
return rows.Err()
}
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
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 < ?
)
GROUP BY e.hostname, e.target_user, e.src_ip
`
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd, lookbackStart, windowStart)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var host, user, srcIP string
var cnt int
if err := rows.Scan(&host, &user, &srcIP, &cnt); err != nil {
return err
}
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",
Severity: severity,
Hostname: host,
Channel: "Security",
EventID: 4624,
Score: score,
WindowStart: windowStart,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Benutzer %s meldet sich auf %s von neuer Quell-IP %s an", user, host, srcIP),
Details: mustJSON(map[string]any{
"user": user,
"src_ip": srcIP,
"count": cnt,
"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()
}
}
return rows.Err()
}
func (d *detector) isDetectionSuppressed(ctx context.Context, det Detection) (bool, error) {
var count int
err := d.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM detection_suppressions
WHERE enabled = 1
AND rule_name = ?
AND (hostname = '' OR hostname = ?)
AND (channel_name = '' OR channel_name = ?)
AND (event_id = 0 OR event_id = ?)
AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP(6))
`,
det.RuleName,
det.Hostname,
det.Channel,
det.EventID,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (d *detector) insertDetection(ctx context.Context, det Detection) (bool, error) {
suppressed, err := d.isDetectionSuppressed(ctx, det)
if err != nil {
return false, err
}
if suppressed {
return false, nil
}
const q = `
INSERT IGNORE INTO detections
(rule_name, severity, hostname, channel_name, event_id, score, window_start, window_end, summary, details_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
res, err := d.db.ExecContext(ctx, q,
det.RuleName,
det.Severity,
det.Hostname,
det.Channel,
det.EventID,
det.Score,
det.WindowStart.UTC(),
det.WindowEnd.UTC(),
det.Summary,
string(det.Details),
)
if err != nil {
return false, err
}
affected, err := res.RowsAffected()
if err != nil {
return false, err
}
return affected > 0, nil
}
func NormalizeEventXML(xmlStr string) NormalizedEvent {
var out NormalizedEvent
if strings.TrimSpace(xmlStr) == "" {
return out
}
dec := xml.NewDecoder(strings.NewReader(xmlStr))
var path []string
var currentDataName string
for {
tok, err := dec.Token()
if err != nil {
if err == io.EOF {
break
}
return out
}
switch t := tok.(type) {
case xml.StartElement:
path = append(path, t.Name.Local)
switch t.Name.Local {
case "Provider":
for _, a := range t.Attr {
if a.Name.Local == "Name" {
out.ProviderName = strings.TrimSpace(a.Value)
}
}
case "Data":
currentDataName = ""
for _, a := range t.Attr {
if a.Name.Local == "Name" {
currentDataName = strings.TrimSpace(a.Value)
break
}
}
}
case xml.EndElement:
if len(path) > 0 {
path = path[:len(path)-1]
}
if t.Name.Local == "Data" {
currentDataName = ""
}
case xml.CharData:
v := strings.TrimSpace(string(t))
if v == "" {
continue
}
if endsWithPath(path, "System", "Computer") {
out.Computer = v
continue
}
if endsWithPath(path, "System", "Level") {
out.LevelValue = parseUint32(v)
continue
}
if endsWithPath(path, "System", "Task") {
out.TaskValue = parseUint32(v)
continue
}
if endsWithPath(path, "System", "Opcode") {
out.OpcodeValue = parseUint32(v)
continue
}
if endsWithPath(path, "System", "Keywords") {
out.Keywords = v
continue
}
if currentDataName != "" {
switch currentDataName {
case "TargetUserName":
out.TargetUser = v
case "TargetDomainName":
out.TargetDomain = v
case "SubjectUserName":
out.SubjectUser = v
case "SubjectDomainName":
out.SubjectDomain = v
case "WorkstationName":
out.Workstation = v
case "IpAddress":
out.SrcIP = v
case "IpPort":
out.SrcPort = v
case "LogonType":
out.LogonType = v
case "ProcessName":
out.ProcessName = v
case "AuthenticationPackageName":
out.AuthenticationPackage = v
case "LogonProcessName":
out.LogonProcess = v
case "Status":
out.StatusText = v
case "SubStatus":
out.SubStatusText = v
case "FailureReason":
out.FailureReason = v
case "MemberName":
out.MemberName = v
}
}
}
}
out.TargetUser = cleanupWinField(out.TargetUser)
out.TargetDomain = cleanupWinField(out.TargetDomain)
out.SubjectUser = cleanupWinField(out.SubjectUser)
out.SubjectDomain = cleanupWinField(out.SubjectDomain)
out.Workstation = cleanupWinField(out.Workstation)
out.SrcIP = cleanupWinField(out.SrcIP)
out.SrcPort = cleanupWinField(out.SrcPort)
out.LogonType = cleanupWinField(out.LogonType)
out.ProcessName = cleanupWinField(out.ProcessName)
out.AuthenticationPackage = cleanupWinField(out.AuthenticationPackage)
out.LogonProcess = cleanupWinField(out.LogonProcess)
out.StatusText = cleanupWinField(out.StatusText)
out.SubStatusText = cleanupWinField(out.SubStatusText)
out.FailureReason = cleanupWinField(out.FailureReason)
out.Computer = cleanupWinField(out.Computer)
out.ProviderName = cleanupWinField(out.ProviderName)
return out
}
func endsWithPath(path []string, parts ...string) bool {
if len(path) < len(parts) {
return false
}
start := len(path) - len(parts)
for i := range parts {
if path[start+i] != parts[i] {
return false
}
}
return true
}
func parseUint32(v string) uint32 {
n, err := strconv.ParseUint(strings.TrimSpace(v), 10, 32)
if err != nil {
return 0
}
return uint32(n)
}
func cleanupWinField(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if v == "-" {
return ""
}
return v
}
func firstNonEmpty(v ...string) string {
for _, s := range v {
if strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
func validatePayload(p *LogPayload) error {
p.Hostname = strings.TrimSpace(p.Hostname)
p.Channel = strings.TrimSpace(p.Channel)
p.Source = strings.TrimSpace(p.Source)
if p.Hostname == "" {
return errors.New("host is required")
}
if len(p.Hostname) > 255 {
return errors.New("host too long")
}
if p.Channel == "" {
return errors.New("channel is required")
}
if len(p.Channel) > 128 {
return errors.New("channel too long")
}
if p.Source == "" {
return errors.New("source is required")
}
if len(p.Source) > 255 {
return errors.New("source too long")
}
if p.EventID == 0 {
return errors.New("event id is required")
}
if p.Message == "" {
return errors.New("msg is required")
}
if len(p.Message) > 2*1024*1024 {
return errors.New("msg too large")
}
if p.Time.IsZero() {
return errors.New("ts is required")
}
return nil
}
func severityFromScore(score, low, medium, high, critical float64) string {
switch {
case score >= critical:
return "critical"
case score >= high:
return "high"
case score >= medium:
return "medium"
case score >= low:
return "low"
default:
return "info"
}
}
func mustJSON(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
func metricsMiddleware(logger *log.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rw := &statusRecorder{ResponseWriter: w, status: 200}
start := time.Now()
next.ServeHTTP(rw, r)
status := strconv.Itoa(rw.status)
path := r.URL.Path
httpRequestsTotal.WithLabelValues(path, r.Method, status).Inc()
httpRequestDuration.WithLabelValues(path, r.Method, status).Observe(time.Since(start).Seconds())
logger.Printf("%s %s remote=%s status=%s dur=%s", r.Method, r.URL.Path, r.RemoteAddr, status, time.Since(start))
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recover() != nil {
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func constantTimeEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
var v byte
for i := 0; i < len(a); i++ {
v |= a[i] ^ b[i]
}
return v == 0
}
func clientIP(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}
func getenv(key, def string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
return v
}
func mustGetenv(key string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
log.Fatalf("missing required environment variable: %s", key)
}
return v
}
func getenvInt(key string, def int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
log.Fatalf("invalid int for %s: %v", key, err)
}
return n
}
func getenvInt64(key string, def int64) int64 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
log.Fatalf("invalid int64 for %s: %v", key, err)
}
return n
}
func getenvDuration(key string, def time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
log.Fatalf("invalid duration for %s: %v", key, err)
}
return d
}