6712 lines
165 KiB
Go
6712 lines
165 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">Overview</a>
|
||
<a href="/ui/soc">SOC</a>
|
||
<a href="/ui/privileged-users">Privileged Users</a>
|
||
<a href="/ui/agents">Agents</a>
|
||
<a href="/ui/rules">Rules</a>
|
||
<a href="/ui/baseline">Baseline</a>
|
||
<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 "privileged_users"}}
|
||
{{template "header" .}}
|
||
<h1>{{.Title}}</h1>
|
||
<p class="muted">Privilegierte Benutzer für UEBA-Regeln wie „Admin auf neuem Host“.</p>
|
||
|
||
<div class="card">
|
||
<h2>Benutzer hinzufügen</h2>
|
||
<form method="post" action="/ui/privileged-users/save">
|
||
<div class="filters">
|
||
<div>
|
||
<label>Username</label>
|
||
<input name="username" placeholder="adm.mustermann oder administrator" required>
|
||
</div>
|
||
<div>
|
||
<label>Grund</label>
|
||
<input name="reason" placeholder="Domain Admin, Server Admin, Helpdesk Admin">
|
||
</div>
|
||
</div>
|
||
<button type="submit">Speichern</button>
|
||
</form>
|
||
</div>
|
||
|
||
<h2>Privileged Users</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<tr>
|
||
<th>Username</th>
|
||
<th>Grund</th>
|
||
<th>Status</th>
|
||
<th>Created</th>
|
||
<th>Updated</th>
|
||
<th>Aktion</th>
|
||
</tr>
|
||
{{range .Users}}
|
||
<tr>
|
||
<td><span class="mono">{{.Username}}</span></td>
|
||
<td>{{.Reason}}</td>
|
||
<td>
|
||
{{if .Enabled}}
|
||
<span class="badge badge-on">aktiv</span>
|
||
{{else}}
|
||
<span class="badge badge-muted">inaktiv</span>
|
||
{{end}}
|
||
</td>
|
||
<td>{{fmtTime .CreatedAt}}</td>
|
||
<td>{{fmtTime .UpdatedAt}}</td>
|
||
<td>
|
||
<form method="post" action="/ui/privileged-users/toggle" class="inline-form">
|
||
<input type="hidden" name="username" value="{{.Username}}">
|
||
{{if .Enabled}}
|
||
<input type="hidden" name="enabled" value="0">
|
||
<button class="danger" type="submit">Deaktivieren</button>
|
||
{{else}}
|
||
<input type="hidden" name="enabled" value="1">
|
||
<button class="success" type="submit">Aktivieren</button>
|
||
{{end}}
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{{end}}
|
||
</table>
|
||
</div>
|
||
|
||
{{template "footer" .}}
|
||
{{end}}
|
||
|
||
{{define "dashboard"}}
|
||
{{template "header" .}}
|
||
<h1>{{.Title}}</h1>
|
||
<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
|
||
|
||
privilegedLogonsTotal *prometheus.CounterVec
|
||
privilegedLogonFailuresTotal *prometheus.CounterVec
|
||
privilegedNewHostTotal *prometheus.CounterVec
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
type PrivilegedUserRow struct {
|
||
Username string
|
||
Reason string
|
||
Enabled bool
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
type PrivilegedUsersPageData struct {
|
||
Title string
|
||
Now time.Time
|
||
Users []PrivilegedUserRow
|
||
}
|
||
|
||
type RawEventInsert struct {
|
||
Message string
|
||
SHA256 string
|
||
}
|
||
|
||
type EventCountBucketAgg struct {
|
||
BucketStart time.Time
|
||
BucketEnd time.Time
|
||
Hostname string
|
||
Channel string
|
||
EventID uint32
|
||
Count uint64
|
||
FirstTS time.Time
|
||
LastTS time.Time
|
||
}
|
||
|
||
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"},
|
||
),
|
||
privilegedLogonsTotal: prometheus.NewCounterVec(
|
||
prometheus.CounterOpts{
|
||
Name: "siem_privileged_logons_total",
|
||
Help: "Successful logons by privileged users.",
|
||
},
|
||
[]string{"user", "host"},
|
||
),
|
||
privilegedLogonFailuresTotal: prometheus.NewCounterVec(
|
||
prometheus.CounterOpts{
|
||
Name: "siem_privileged_logon_failures_total",
|
||
Help: "Failed logons by privileged users.",
|
||
},
|
||
[]string{"user", "host"},
|
||
),
|
||
privilegedNewHostTotal: prometheus.NewCounterVec(
|
||
prometheus.CounterOpts{
|
||
Name: "siem_privileged_new_host_total",
|
||
Help: "Privileged user logged on to a new host.",
|
||
},
|
||
[]string{"user", "host"},
|
||
),
|
||
}
|
||
reg.MustRegister(
|
||
d.lastSeenGauge,
|
||
d.activeAgentsGauge,
|
||
d.anomalyScoreGauge,
|
||
d.detectionHitsTotal,
|
||
d.ruleLastRunGauge,
|
||
d.ruleRuntimeHist,
|
||
d.ruleErrorsTotal,
|
||
d.baselineCurrentCountGauge,
|
||
d.baselineAverageGauge,
|
||
d.baselineStddevGauge,
|
||
d.baselineSamplesGauge,
|
||
d.hostRiskScoreGauge,
|
||
d.privilegedLogonsTotal,
|
||
d.privilegedLogonFailuresTotal,
|
||
d.privilegedNewHostTotal,
|
||
)
|
||
|
||
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 normalizeTime(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.runSOCLoop()
|
||
|
||
go s.runDetectionLoop()
|
||
go s.runBaselineLoop()
|
||
|
||
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)
|
||
mux.HandleFunc("/ui/privileged-users", s.handleUIPrivilegedUsers)
|
||
mux.HandleFunc("/ui/privileged-users/save", s.handleUIPrivilegedUserSave)
|
||
mux.HandleFunc("/ui/privileged-users/toggle", s.handleUIPrivilegedUserToggle)
|
||
|
||
httpSrv := &http.Server{
|
||
Addr: cfg.ListenAddr,
|
||
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 bucketStart(t time.Time, size time.Duration) time.Time {
|
||
t = t.UTC()
|
||
|
||
if size <= 0 {
|
||
size = 5 * time.Minute
|
||
}
|
||
|
||
unix := t.Unix()
|
||
bucketSeconds := int64(size.Seconds())
|
||
start := unix - (unix % bucketSeconds)
|
||
|
||
return time.Unix(start, 0).UTC()
|
||
}
|
||
|
||
func upsertEventCountBucketsTx(ctx context.Context, tx *sql.Tx, buckets map[string]*EventCountBucketAgg) error {
|
||
if len(buckets) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var sb strings.Builder
|
||
args := make([]any, 0, len(buckets)*8)
|
||
|
||
sb.WriteString(`
|
||
INSERT INTO event_count_buckets
|
||
(bucket_start, bucket_end, hostname, channel_name, event_id, cnt, first_event_ts, last_event_ts)
|
||
VALUES
|
||
`)
|
||
|
||
i := 0
|
||
for _, b := range buckets {
|
||
if i > 0 {
|
||
sb.WriteString(",")
|
||
}
|
||
i++
|
||
|
||
sb.WriteString("(?,?,?,?,?,?,?,?)")
|
||
|
||
args = append(args,
|
||
b.BucketStart.UTC(),
|
||
b.BucketEnd.UTC(),
|
||
b.Hostname,
|
||
b.Channel,
|
||
b.EventID,
|
||
b.Count,
|
||
b.FirstTS.UTC(),
|
||
b.LastTS.UTC(),
|
||
)
|
||
}
|
||
|
||
sb.WriteString(`
|
||
ON DUPLICATE KEY UPDATE
|
||
cnt = cnt + VALUES(cnt),
|
||
first_event_ts = LEAST(first_event_ts, VALUES(first_event_ts)),
|
||
last_event_ts = GREATEST(last_event_ts, VALUES(last_event_ts)),
|
||
finalized = 0,
|
||
updated_at = UTC_TIMESTAMP(6)
|
||
`)
|
||
|
||
_, err := tx.ExecContext(ctx, sb.String(), args...)
|
||
if err != nil {
|
||
return fmt.Errorf("upsert event_count_buckets: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *server) runSOCLoop() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
|
||
s.runSOCOnce()
|
||
|
||
for range ticker.C {
|
||
s.runSOCOnce()
|
||
}
|
||
}
|
||
|
||
func (s *server) runBaselineLoop() {
|
||
ticker := time.NewTicker(s.cfg.DetectionInterval)
|
||
defer ticker.Stop()
|
||
|
||
s.runBaselineOnce()
|
||
|
||
for range ticker.C {
|
||
s.runBaselineOnce()
|
||
}
|
||
}
|
||
|
||
func (s *server) runBaselineOnce() {
|
||
if !s.cfg.BaselineEnabled {
|
||
return
|
||
}
|
||
|
||
rules := []struct {
|
||
name string
|
||
timeout time.Duration
|
||
fn func(context.Context) error
|
||
}{
|
||
{"baseline_finalize_buckets", 30 * time.Second, s.detector.finalizeEventCountBuckets},
|
||
{"baseline_anomaly", 120 * time.Second, s.detector.runBaselineAnomalyRule},
|
||
{"baseline_update", 120 * time.Second, s.detector.runBaselineUpdate},
|
||
}
|
||
|
||
for _, rule := range rules {
|
||
start := time.Now()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), rule.timeout)
|
||
err := rule.fn(ctx)
|
||
cancel()
|
||
|
||
dur := time.Since(start)
|
||
|
||
if err != nil {
|
||
s.logger.Printf("baseline rule %s error after %s: %v", rule.name, dur, err)
|
||
s.detector.ruleErrorsTotal.WithLabelValues(rule.name).Inc()
|
||
continue
|
||
}
|
||
|
||
s.logger.Printf("baseline rule %s completed in %s", rule.name, dur)
|
||
|
||
s.detector.ruleLastRunGauge.WithLabelValues(rule.name).Set(float64(time.Now().Unix()))
|
||
s.detector.ruleRuntimeHist.WithLabelValues(rule.name).Observe(dur.Seconds())
|
||
}
|
||
}
|
||
|
||
func (s *server) runSOCOnce() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
start := time.Now()
|
||
|
||
if err := s.detector.runHostRiskScoreUpdate(ctx); err != nil {
|
||
s.logger.Printf("soc host risk update error after %s: %v", time.Since(start), err)
|
||
s.detector.ruleErrorsTotal.WithLabelValues("host_risk_score").Inc()
|
||
return
|
||
}
|
||
|
||
s.detector.ruleLastRunGauge.WithLabelValues("host_risk_score").Set(float64(time.Now().Unix()))
|
||
s.detector.ruleRuntimeHist.WithLabelValues("host_risk_score").Observe(time.Since(start).Seconds())
|
||
|
||
s.logger.Printf("soc host risk update completed in %s", time.Since(start))
|
||
}
|
||
|
||
func normalizeTime(t time.Time) time.Time {
|
||
if t.IsZero() {
|
||
return t
|
||
}
|
||
|
||
// Fall 1:
|
||
// MySQL DATETIME wird mit loc=UTC gelesen.
|
||
// Dann ist t.Location() bereits UTC und alles ist gut.
|
||
if t.Location() == time.UTC {
|
||
return t
|
||
}
|
||
|
||
// Fall 2:
|
||
// Der MySQL-Treiber hat die Zeit bereits als echten Zeitpunkt mit Location gelesen.
|
||
// Dann NICHT die Uhrzeit neu als UTC interpretieren, sondern Instant nach UTC konvertieren.
|
||
name := t.Location().String()
|
||
if name != "" && name != "Local" {
|
||
return t.UTC()
|
||
}
|
||
|
||
// Fall 3:
|
||
// MySQL DATETIME ohne echte Zeitzone wurde als time.Local interpretiert.
|
||
// In deiner Anwendung speicherst du DB-Zeiten logisch als UTC.
|
||
// Deshalb interpretieren wir die Wandzeit als UTC.
|
||
return time.Date(
|
||
t.Year(),
|
||
t.Month(),
|
||
t.Day(),
|
||
t.Hour(),
|
||
t.Minute(),
|
||
t.Second(),
|
||
t.Nanosecond(),
|
||
time.UTC,
|
||
)
|
||
}
|
||
|
||
func (s *server) listPrivilegedUsers(ctx context.Context) ([]PrivilegedUserRow, error) {
|
||
rows, err := s.db.QueryContext(ctx, `
|
||
SELECT username, COALESCE(reason, ''), enabled, created_at, updated_at
|
||
FROM privileged_users
|
||
ORDER BY username ASC
|
||
`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []PrivilegedUserRow
|
||
for rows.Next() {
|
||
var u PrivilegedUserRow
|
||
if err := rows.Scan(&u.Username, &u.Reason, &u.Enabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, u)
|
||
}
|
||
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) handleUIPrivilegedUsers(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
users, err := s.listPrivilegedUsers(ctx)
|
||
if err != nil {
|
||
s.logger.Printf("privileged users: %v", err)
|
||
writeError(w, http.StatusInternalServerError, "internal error")
|
||
return
|
||
}
|
||
|
||
s.renderTemplate(w, "privileged_users", PrivilegedUsersPageData{
|
||
Title: "Privileged Users",
|
||
Now: time.Now().UTC(),
|
||
Users: users,
|
||
})
|
||
}
|
||
|
||
func (s *server) handleUIPrivilegedUserSave(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
return
|
||
}
|
||
|
||
if err := r.ParseForm(); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid form")
|
||
return
|
||
}
|
||
|
||
username := normalizeUsername(r.FormValue("username"))
|
||
reason := strings.TrimSpace(r.FormValue("reason"))
|
||
|
||
if username == "" || strings.HasSuffix(username, "$") {
|
||
writeError(w, http.StatusBadRequest, "invalid username")
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
_, err := s.db.ExecContext(ctx, `
|
||
INSERT INTO privileged_users (username, reason, enabled)
|
||
VALUES (?, ?, 1)
|
||
ON DUPLICATE KEY UPDATE
|
||
reason = VALUES(reason),
|
||
enabled = 1,
|
||
updated_at = UTC_TIMESTAMP(6)
|
||
`, username, reason)
|
||
if err != nil {
|
||
s.logger.Printf("save privileged user: %v", err)
|
||
writeError(w, http.StatusInternalServerError, "internal error")
|
||
return
|
||
}
|
||
|
||
http.Redirect(w, r, "/ui/privileged-users", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *server) handleUIPrivilegedUserToggle(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
return
|
||
}
|
||
|
||
if err := r.ParseForm(); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid form")
|
||
return
|
||
}
|
||
|
||
username := normalizeUsername(r.FormValue("username"))
|
||
enabled := strings.TrimSpace(r.FormValue("enabled")) == "1"
|
||
|
||
if username == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid username")
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
_, err := s.db.ExecContext(ctx, `
|
||
UPDATE privileged_users
|
||
SET enabled = ?,
|
||
updated_at = UTC_TIMESTAMP(6)
|
||
WHERE username = ?
|
||
`, enabled, username)
|
||
if err != nil {
|
||
s.logger.Printf("toggle privileged user: %v", err)
|
||
writeError(w, http.StatusInternalServerError, "internal error")
|
||
return
|
||
}
|
||
|
||
http.Redirect(w, r, "/ui/privileged-users", http.StatusSeeOther)
|
||
}
|
||
|
||
func (s *server) handleUIDetectionsBatchUpdate(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
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) getRawEventXMLByID(ctx context.Context, id uint64) (string, error) {
|
||
var msg string
|
||
|
||
err := s.db.QueryRowContext(ctx, `
|
||
SELECT msg
|
||
FROM event_log_raw
|
||
WHERE event_log_id = ?
|
||
LIMIT 1
|
||
`, id).Scan(&msg)
|
||
|
||
if err == nil {
|
||
return msg, nil
|
||
}
|
||
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
return "", err
|
||
}
|
||
|
||
// Fallback für Alt-Daten, falls event_log_raw noch nicht vollständig befüllt ist.
|
||
err = s.db.QueryRowContext(ctx, `
|
||
SELECT COALESCE(msg, '')
|
||
FROM event_logs
|
||
WHERE id = ?
|
||
LIMIT 1
|
||
`, id).Scan(&msg)
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return msg, nil
|
||
}
|
||
|
||
func eventListSummary(ev EventRow) string {
|
||
user := firstNonEmpty(ev.TargetUser, ev.SubjectUser)
|
||
parts := []string{
|
||
fmt.Sprintf("%s EventID %d", ev.Channel, ev.EventID),
|
||
}
|
||
|
||
if user != "" {
|
||
parts = append(parts, "User="+user)
|
||
}
|
||
if ev.SrcIP != "" {
|
||
parts = append(parts, "IP="+ev.SrcIP)
|
||
}
|
||
if ev.Workstation != "" {
|
||
parts = append(parts, "Workstation="+ev.Workstation)
|
||
}
|
||
if ev.ProcessName != "" {
|
||
parts = append(parts, "Process="+ev.ProcessName)
|
||
}
|
||
if ev.FailureReason != "" {
|
||
parts = append(parts, "Reason="+ev.FailureReason)
|
||
}
|
||
|
||
return strings.Join(parts, " | ")
|
||
}
|
||
|
||
func (s *server) listSOCRecentIncidents(ctx context.Context, limit int) ([]SOCRecentIncidentRow, error) {
|
||
if limit <= 0 || limit > 500 {
|
||
limit = 50
|
||
}
|
||
|
||
rows, err := s.db.QueryContext(ctx, `
|
||
SELECT id, created_at, rule_name, severity, status, hostname, summary
|
||
FROM (
|
||
SELECT id, created_at, rule_name, severity, status, hostname, summary
|
||
FROM (
|
||
SELECT id, created_at, rule_name, severity, status, hostname, summary
|
||
FROM detections
|
||
WHERE status IN ('open', 'investigating', 'confirmed_incident')
|
||
ORDER BY created_at DESC
|
||
LIMIT ?
|
||
) s
|
||
|
||
UNION
|
||
|
||
SELECT id, created_at, rule_name, severity, status, hostname, summary
|
||
FROM (
|
||
SELECT id, created_at, rule_name, severity, status, hostname, summary
|
||
FROM detections
|
||
WHERE severity IN ('high', 'critical')
|
||
ORDER BY created_at DESC
|
||
LIMIT ?
|
||
) sev
|
||
) x
|
||
ORDER BY created_at DESC
|
||
LIMIT ?
|
||
`, limit, 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
|
||
}
|
||
|
||
r.CreatedAt = normalizeTime(r.CreatedAt)
|
||
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(), 15*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().UTC(),
|
||
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(), 15*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(), 15*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().UTC(),
|
||
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(), 15*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().UTC(),
|
||
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(), 15*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),
|
||
}
|
||
|
||
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(), 15*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(), 15*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(), 15*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(), 15*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(), 15*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
|
||
}
|
||
|
||
timeFrom := strings.TrimSpace(r.URL.Query().Get("from"))
|
||
timeTo := strings.TrimSpace(r.URL.Query().Get("to"))
|
||
|
||
if timeFrom == "" && timeTo == "" {
|
||
timeFrom = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
|
||
}
|
||
|
||
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: timeFrom,
|
||
TimeTo: timeTo,
|
||
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(), 15*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
|
||
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
|
||
AND d.created_at >= DATE_SUB(event_logs.ts, INTERVAL 1 DAY)
|
||
`
|
||
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,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ev.Time = normalizeTime(ev.Time)
|
||
ev.ReceivedAt = normalizeTime(ev.ReceivedAt)
|
||
ev.Message = eventListSummary(ev)
|
||
|
||
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
|
||
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,
|
||
)
|
||
if err != nil {
|
||
return ev, err
|
||
}
|
||
|
||
ev.Time = normalizeTime(ev.Time)
|
||
ev.ReceivedAt = normalizeTime(ev.ReceivedAt)
|
||
|
||
raw, err := s.getRawEventXMLByID(ctx, id)
|
||
if err != nil {
|
||
return ev, err
|
||
}
|
||
|
||
ev.Message = raw
|
||
return ev, nil
|
||
}
|
||
|
||
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(), 15*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, received_at, msg, msg_sha256
|
||
) VALUES
|
||
`)
|
||
|
||
bucketAgg := make(map[string]*EventCountBucketAgg)
|
||
rawEvents := make([]RawEventInsert, 0, len(batch))
|
||
|
||
for i, item := range batch {
|
||
if i > 0 {
|
||
sb.WriteString(",")
|
||
}
|
||
sb.WriteString("(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(6),'',?)")
|
||
|
||
msgHash := sha256Hex(item.Message)
|
||
rawEvents = append(rawEvents, RawEventInsert{
|
||
Message: item.Message,
|
||
SHA256: msgHash,
|
||
})
|
||
|
||
norm := NormalizeEventXML(item.Message)
|
||
|
||
realHost := firstNonEmpty(norm.Computer, item.Hostname)
|
||
targetUser := normalizeUsername(norm.TargetUser)
|
||
subjectUser := normalizeUsername(norm.SubjectUser)
|
||
|
||
bucketSize := s.cfg.BaselineWindow
|
||
if bucketSize <= 0 {
|
||
bucketSize = 5 * time.Minute
|
||
}
|
||
|
||
bs := bucketStart(item.Time, bucketSize)
|
||
be := bs.Add(bucketSize)
|
||
|
||
bucketKey := fmt.Sprintf(
|
||
"%s|%s|%s|%d",
|
||
bs.Format(time.RFC3339Nano),
|
||
realHost,
|
||
item.Channel,
|
||
item.EventID,
|
||
)
|
||
|
||
agg := bucketAgg[bucketKey]
|
||
if agg == nil {
|
||
agg = &EventCountBucketAgg{
|
||
BucketStart: bs,
|
||
BucketEnd: be,
|
||
Hostname: realHost,
|
||
Channel: item.Channel,
|
||
EventID: item.EventID,
|
||
FirstTS: item.Time.UTC(),
|
||
LastTS: item.Time.UTC(),
|
||
}
|
||
bucketAgg[bucketKey] = agg
|
||
}
|
||
|
||
agg.Count++
|
||
|
||
eventTS := item.Time.UTC()
|
||
if eventTS.Before(agg.FirstTS) {
|
||
agg.FirstTS = eventTS
|
||
}
|
||
if eventTS.After(agg.LastTS) {
|
||
agg.LastTS = eventTS
|
||
}
|
||
|
||
// Erfolgreicher Logon
|
||
if item.Channel == "Security" && item.EventID == 4624 && targetUser != "" {
|
||
priv, err := s.detector.isPrivilegedUser(ctx, targetUser)
|
||
if err != nil {
|
||
s.logger.Printf("privileged user check failed for %s: %v", targetUser, err)
|
||
} else if priv {
|
||
s.detector.privilegedLogonsTotal.WithLabelValues(targetUser, realHost).Inc()
|
||
}
|
||
}
|
||
|
||
// Fehlgeschlagener Logon
|
||
if item.Channel == "Security" && item.EventID == 4625 && targetUser != "" {
|
||
priv, err := s.detector.isPrivilegedUser(ctx, targetUser)
|
||
if err != nil {
|
||
s.logger.Printf("privileged user check failed for %s: %v", targetUser, err)
|
||
} else if priv {
|
||
s.detector.privilegedLogonFailuresTotal.WithLabelValues(targetUser, realHost).Inc()
|
||
}
|
||
}
|
||
|
||
// Special Privileged Logon
|
||
if item.Channel == "Security" && item.EventID == 4672 && subjectUser != "" {
|
||
priv, err := s.detector.isPrivilegedUser(ctx, subjectUser)
|
||
if err != nil {
|
||
s.logger.Printf("privileged user check failed for %s: %v", subjectUser, err)
|
||
} else if priv {
|
||
s.detector.privilegedLogonsTotal.WithLabelValues(subjectUser, realHost).Inc()
|
||
}
|
||
}
|
||
|
||
args = append(args,
|
||
agentID,
|
||
realHost,
|
||
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(),
|
||
msgHash,
|
||
)
|
||
}
|
||
|
||
res, err := tx.ExecContext(ctx, sb.String(), args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
firstID, err := res.LastInsertId()
|
||
if err != nil {
|
||
return fmt.Errorf("event_logs LastInsertId: %w", err)
|
||
}
|
||
|
||
affected, err := res.RowsAffected()
|
||
if err != nil {
|
||
return fmt.Errorf("event_logs RowsAffected: %w", err)
|
||
}
|
||
|
||
if affected != int64(len(batch)) {
|
||
return fmt.Errorf("event_logs insert affected %d rows, expected %d", affected, len(batch))
|
||
}
|
||
|
||
if err := insertRawEventsTx(ctx, tx, uint64(firstID), rawEvents); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := upsertEventCountBucketsTx(ctx, tx, bucketAgg); 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 (d *detector) finalizeEventCountBuckets(ctx context.Context) error {
|
||
_, err := d.db.ExecContext(ctx, `
|
||
UPDATE event_count_buckets
|
||
SET finalized = 1,
|
||
updated_at = UTC_TIMESTAMP(6)
|
||
WHERE finalized = 0
|
||
AND bucket_end < UTC_TIMESTAMP(6) - INTERVAL 2 MINUTE
|
||
`)
|
||
return err
|
||
}
|
||
|
||
func (d *detector) markBucketAnomalyChecked(ctx context.Context, bucketStart time.Time, hostname, channel string, eventID uint32) error {
|
||
_, err := d.db.ExecContext(ctx, `
|
||
UPDATE event_count_buckets
|
||
SET anomaly_checked_at = UTC_TIMESTAMP(6)
|
||
WHERE bucket_start = ?
|
||
AND hostname = ?
|
||
AND channel_name = ?
|
||
AND event_id = ?
|
||
`, bucketStart.UTC(), hostname, channel, eventID)
|
||
|
||
return err
|
||
}
|
||
|
||
func (d *detector) markBucketLearned(ctx context.Context, bucketStart time.Time, hostname, channel string, eventID uint32) error {
|
||
_, err := d.db.ExecContext(ctx, `
|
||
UPDATE event_count_buckets
|
||
SET learned_at = UTC_TIMESTAMP(6)
|
||
WHERE bucket_start = ?
|
||
AND hostname = ?
|
||
AND channel_name = ?
|
||
AND event_id = ?
|
||
`, bucketStart.UTC(), hostname, channel, eventID)
|
||
|
||
return err
|
||
}
|
||
|
||
func insertRawEventsTx(ctx context.Context, tx *sql.Tx, firstEventID uint64, rawEvents []RawEventInsert) error {
|
||
if len(rawEvents) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var sb strings.Builder
|
||
args := make([]any, 0, len(rawEvents)*3)
|
||
|
||
sb.WriteString(`
|
||
INSERT INTO event_log_raw
|
||
(event_log_id, msg, msg_sha256)
|
||
VALUES
|
||
`)
|
||
|
||
for i, raw := range rawEvents {
|
||
if i > 0 {
|
||
sb.WriteString(",")
|
||
}
|
||
|
||
sb.WriteString("(?,?,?)")
|
||
|
||
args = append(args,
|
||
firstEventID+uint64(i),
|
||
raw.Message,
|
||
raw.SHA256,
|
||
)
|
||
}
|
||
|
||
_, err := tx.ExecContext(ctx, sb.String(), args...)
|
||
if err != nil {
|
||
return fmt.Errorf("insert event_log_raw: %w", err)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
rows, err := d.db.QueryContext(ctx, `
|
||
SELECT
|
||
bucket_start,
|
||
bucket_end,
|
||
hostname,
|
||
channel_name,
|
||
event_id,
|
||
cnt
|
||
FROM event_count_buckets
|
||
WHERE finalized = 1
|
||
AND learned_at IS NULL
|
||
ORDER BY bucket_start ASC
|
||
LIMIT 5000
|
||
`)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var bucketStart time.Time
|
||
var bucketEnd time.Time
|
||
var b BaselineBucket
|
||
|
||
if err := rows.Scan(
|
||
&bucketStart,
|
||
&bucketEnd,
|
||
&b.Hostname,
|
||
&b.Channel,
|
||
&b.EventID,
|
||
&b.Count,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
|
||
bucketStart = bucketStart.UTC()
|
||
bucketEnd = bucketEnd.UTC()
|
||
|
||
b.Hour = bucketStart.Hour()
|
||
b.DayOfWeek = mysqlWeekday(bucketStart)
|
||
|
||
excluded, err := d.isBaselineExcluded(ctx, b.Hostname, b.Channel, b.EventID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if !excluded {
|
||
incident, err := d.hasConfirmedIncidentInWindow(ctx, b.Hostname, b.Channel, b.EventID, bucketStart, bucketEnd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if !incident {
|
||
if err := d.updateBaselineBucket(ctx, b); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := d.markBucketLearned(ctx, bucketStart, b.Hostname, b.Channel, b.EventID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return rows.Err()
|
||
}
|
||
|
||
func mysqlWeekday(t time.Time) int {
|
||
// MySQL WEEKDAY(): Monday=0 ... Sunday=6
|
||
// Go Weekday(): Sunday=0 ... Saturday=6
|
||
return (int(t.UTC().Weekday()) + 6) % 7
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
rows, err := d.db.QueryContext(ctx, `
|
||
SELECT
|
||
e.bucket_start,
|
||
e.bucket_end,
|
||
e.hostname,
|
||
e.channel_name,
|
||
e.event_id,
|
||
e.cnt,
|
||
COALESCE(b.avg_count, 0),
|
||
COALESCE(b.stddev_count, 0),
|
||
COALESCE(b.sample_count, 0)
|
||
FROM event_count_buckets e
|
||
LEFT 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.bucket_start)
|
||
AND b.day_of_week = WEEKDAY(e.bucket_start)
|
||
WHERE e.finalized = 1
|
||
AND e.anomaly_checked_at IS NULL
|
||
ORDER BY e.bucket_start ASC
|
||
LIMIT 5000
|
||
`)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var bucketStart time.Time
|
||
var bucketEnd time.Time
|
||
var host string
|
||
var channel string
|
||
var eventID uint32
|
||
var count int
|
||
var avg float64
|
||
var stddev float64
|
||
var samples int
|
||
|
||
if err := rows.Scan(
|
||
&bucketStart,
|
||
&bucketEnd,
|
||
&host,
|
||
&channel,
|
||
&eventID,
|
||
&count,
|
||
&avg,
|
||
&stddev,
|
||
&samples,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
|
||
bucketStart = bucketStart.UTC()
|
||
bucketEnd = bucketEnd.UTC()
|
||
|
||
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 &&
|
||
count >= d.cfg.BaselineMinCount &&
|
||
stddev > 0 {
|
||
|
||
z := (float64(count) - avg) / stddev
|
||
|
||
if z >= d.cfg.BaselineMediumZScore {
|
||
severity := "medium"
|
||
if z >= d.cfg.BaselineHighZScore {
|
||
severity = "high"
|
||
}
|
||
|
||
suppressed, err := d.isBaselineSuppressed(ctx, host, channel, eventID, bucketEnd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if !suppressed {
|
||
created, err := d.insertDetection(ctx, Detection{
|
||
RuleName: "baseline_event_rate_anomaly",
|
||
Severity: severity,
|
||
Hostname: host,
|
||
Channel: channel,
|
||
EventID: eventID,
|
||
Score: z,
|
||
WindowStart: bucketStart,
|
||
WindowEnd: bucketEnd,
|
||
Summary: fmt.Sprintf(
|
||
"Baseline-Anomalie auf %s: %s EventID %d kam %d-mal im Bucket %s bis %s, normal %.2f ± %.2f, z=%.2f",
|
||
host,
|
||
channel,
|
||
eventID,
|
||
count,
|
||
bucketStart.Format(time.RFC3339),
|
||
bucketEnd.Format(time.RFC3339),
|
||
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": bucketStart.Hour(),
|
||
"day_of_week": mysqlWeekday(bucketStart),
|
||
"bucket_start": bucketStart.Format(time.RFC3339Nano),
|
||
"bucket_end": bucketEnd.Format(time.RFC3339Nano),
|
||
"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(z)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := d.markBucketAnomalyChecked(ctx, bucketStart, host, channel, eventID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
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},
|
||
|
||
{"ueba_admin_new_host", s.detector.runAdminNewHostRule},
|
||
{"ueba_offhours_login", s.detector.runOffHoursLoginRule},
|
||
{"ueba_first_privileged_use", s.detector.runFirstTimePrivilegedRule},
|
||
{"ueba_new_user_context", s.detector.runUEBANewUserContextRule},
|
||
{"ueba_update", s.detector.runUEBABaselineUpdate},
|
||
}
|
||
|
||
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)
|
||
|
||
const q = `
|
||
SELECT
|
||
e.hostname,
|
||
e.target_user,
|
||
e.src_ip,
|
||
e.workstation,
|
||
MIN(e.ts) AS first_seen,
|
||
COUNT(*) AS cnt
|
||
FROM event_logs e
|
||
WHERE e.channel_name = 'Security'
|
||
AND e.event_id = 4624
|
||
AND e.ts >= ? AND e.ts < ?
|
||
AND e.target_user <> ''
|
||
AND e.target_user <> '-'
|
||
AND e.target_user NOT LIKE '%$'
|
||
AND LOWER(e.target_user) NOT IN (
|
||
'system',
|
||
'localsystem',
|
||
'local service',
|
||
'network service',
|
||
'anonymous logon'
|
||
)
|
||
GROUP BY e.hostname, e.target_user, e.src_ip, e.workstation
|
||
`
|
||
|
||
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var host, user, srcIP, workstation string
|
||
var firstSeen time.Time
|
||
var count int
|
||
|
||
if err := rows.Scan(&host, &user, &srcIP, &workstation, &firstSeen, &count); err != nil {
|
||
return err
|
||
}
|
||
|
||
user = normalizeUsername(user)
|
||
if isNoiseAccount(user) {
|
||
continue
|
||
}
|
||
|
||
isNew, err := d.touchUEBAUserContext(ctx, user, host, srcIP, workstation, firstSeen, count)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if !isNew {
|
||
continue
|
||
}
|
||
|
||
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,
|
||
"host": host,
|
||
"count": count,
|
||
"first_seen": firstSeen.UTC().Format(time.RFC3339Nano),
|
||
"window": d.cfg.UEBANewContextWindow.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)
|
||
|
||
const q = `
|
||
SELECT e.hostname, e.target_user, e.src_ip, MIN(e.ts) AS first_seen, COUNT(*) AS cnt
|
||
FROM event_logs e
|
||
WHERE e.channel_name = 'Security'
|
||
AND e.event_id = 4624
|
||
AND e.ts >= ? AND e.ts < ?
|
||
AND e.target_user <> ''
|
||
AND e.target_user <> '-'
|
||
AND e.target_user NOT LIKE '%$'
|
||
AND e.src_ip <> ''
|
||
AND e.src_ip <> '-'
|
||
AND e.src_ip <> '::1'
|
||
AND e.src_ip <> '127.0.0.1'
|
||
AND LOWER(e.target_user) NOT IN (
|
||
'system',
|
||
'localsystem',
|
||
'local service',
|
||
'network service',
|
||
'anonymous logon'
|
||
)
|
||
GROUP BY e.hostname, e.target_user, e.src_ip
|
||
`
|
||
|
||
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var host, user, srcIP string
|
||
var firstSeen time.Time
|
||
var cnt int
|
||
|
||
if err := rows.Scan(&host, &user, &srcIP, &firstSeen, &cnt); err != nil {
|
||
return err
|
||
}
|
||
|
||
user = normalizeUsername(user)
|
||
if isNoiseAccount(user) {
|
||
continue
|
||
}
|
||
|
||
isNew, err := d.touchUserSourceIPSeen(ctx, user, srcIP, host, firstSeen, cnt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if !isNew {
|
||
continue
|
||
}
|
||
|
||
score := 1.5 + math.Log10(float64(cnt)+1)
|
||
|
||
severity := "medium"
|
||
if cnt >= 5 {
|
||
severity = "high"
|
||
}
|
||
|
||
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 erstmals von Quell-IP %s an", user, host, srcIP),
|
||
Details: mustJSON(map[string]any{
|
||
"user": user,
|
||
"src_ip": srcIP,
|
||
"host": host,
|
||
"count": cnt,
|
||
"first_seen": firstSeen.UTC().Format(time.RFC3339Nano),
|
||
"window_minutes": int(d.cfg.NewSourceIPWindow.Minutes()),
|
||
"event_id": 4624,
|
||
}),
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if created {
|
||
d.detectionHitsTotal.WithLabelValues("new_source_ip_for_user", severity).Inc()
|
||
d.anomalyScoreGauge.WithLabelValues(host, "new_source_ip_for_user").Set(score)
|
||
}
|
||
}
|
||
|
||
return rows.Err()
|
||
}
|
||
|
||
func (d *detector) touchUEBAUserContext(ctx context.Context, username, hostname, srcIP, workstation string, firstSeen time.Time, count int) (bool, error) {
|
||
username = normalizeUsername(username)
|
||
hostname = strings.TrimSpace(hostname)
|
||
srcIP = strings.TrimSpace(srcIP)
|
||
workstation = strings.TrimSpace(workstation)
|
||
|
||
if username == "" || hostname == "" {
|
||
return false, nil
|
||
}
|
||
|
||
if srcIP == "" {
|
||
srcIP = "-"
|
||
}
|
||
if workstation == "" {
|
||
workstation = "-"
|
||
}
|
||
|
||
res, err := d.db.ExecContext(ctx, `
|
||
INSERT INTO ueba_user_baseline
|
||
(username, hostname, src_ip, workstation, first_seen, last_seen, seen_count)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
last_seen = VALUES(last_seen),
|
||
seen_count = seen_count + VALUES(seen_count)
|
||
`,
|
||
username,
|
||
hostname,
|
||
srcIP,
|
||
workstation,
|
||
firstSeen.UTC(),
|
||
firstSeen.UTC(),
|
||
count,
|
||
)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
affected, err := res.RowsAffected()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
// MySQL:
|
||
// 1 = neuer Insert
|
||
// 2 = Update wegen ON DUPLICATE KEY
|
||
// 0 = kein effektives Update, abhängig von Client/Settings möglich
|
||
return affected == 1, nil
|
||
}
|
||
|
||
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, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(6))
|
||
`
|
||
|
||
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
|
||
}
|
||
|
||
func (d *detector) runOffHoursLoginRule(ctx context.Context) error {
|
||
if !d.cfg.UEBAEnabled {
|
||
return nil
|
||
}
|
||
|
||
nowLocal := time.Now().Local()
|
||
hour := nowLocal.Hour()
|
||
|
||
// Arbeitszeit: 6–20 Uhr
|
||
if hour >= 6 && hour <= 20 {
|
||
return nil
|
||
}
|
||
|
||
windowEnd := time.Now().UTC()
|
||
windowStart := windowEnd.Add(-5 * time.Minute)
|
||
|
||
const q = `
|
||
SELECT hostname, target_user, COUNT(*) AS cnt
|
||
FROM event_logs
|
||
WHERE channel_name = 'Security'
|
||
AND event_id = 4624
|
||
AND ts >= ? AND ts < ?
|
||
AND target_user <> ''
|
||
AND target_user <> '-'
|
||
AND target_user NOT LIKE '%$'
|
||
AND logon_type IN ('2', '7', '10', '11')
|
||
AND LOWER(target_user) NOT IN (
|
||
'system',
|
||
'localsystem',
|
||
'local service',
|
||
'network service',
|
||
'anonymous logon'
|
||
)
|
||
GROUP BY hostname, target_user
|
||
`
|
||
|
||
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var host, user string
|
||
var count int
|
||
|
||
if err := rows.Scan(&host, &user, &count); err != nil {
|
||
return err
|
||
}
|
||
|
||
user = normalizeUsername(user)
|
||
if isNoiseAccount(user) {
|
||
continue
|
||
}
|
||
|
||
score := 2.0
|
||
severity := "medium"
|
||
|
||
if count >= 5 {
|
||
score = 4.0
|
||
severity = "high"
|
||
}
|
||
|
||
created, err := d.insertDetection(ctx, Detection{
|
||
RuleName: "ueba_offhours_login",
|
||
Severity: severity,
|
||
Hostname: host,
|
||
Channel: "Security",
|
||
EventID: 4624,
|
||
Score: score,
|
||
Summary: fmt.Sprintf("UEBA: Login außerhalb der Arbeitszeit: Benutzer %s auf Host %s", user, host),
|
||
WindowStart: windowStart,
|
||
WindowEnd: windowEnd,
|
||
Details: mustJSON(map[string]any{
|
||
"user": user,
|
||
"host": host,
|
||
"local_hour": hour,
|
||
"count": count,
|
||
}),
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if created {
|
||
d.detectionHitsTotal.WithLabelValues("ueba_offhours_login", severity).Inc()
|
||
d.anomalyScoreGauge.WithLabelValues(host, "ueba_offhours_login").Set(score)
|
||
}
|
||
}
|
||
|
||
return rows.Err()
|
||
}
|
||
|
||
func (d *detector) touchUserSourceIPSeen(ctx context.Context, username, srcIP, hostname string, firstSeen time.Time, count int) (bool, error) {
|
||
username = normalizeUsername(username)
|
||
hostname = strings.TrimSpace(hostname)
|
||
srcIP = strings.TrimSpace(srcIP)
|
||
|
||
if username == "" || srcIP == "" || hostname == "" {
|
||
return false, nil
|
||
}
|
||
|
||
res, err := d.db.ExecContext(ctx, `
|
||
INSERT INTO user_source_ip_seen
|
||
(username, src_ip, hostname, first_seen, last_seen, seen_count)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
last_seen = VALUES(last_seen),
|
||
seen_count = seen_count + VALUES(seen_count)
|
||
`,
|
||
username,
|
||
srcIP,
|
||
hostname,
|
||
firstSeen.UTC(),
|
||
firstSeen.UTC(),
|
||
count,
|
||
)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
affected, err := res.RowsAffected()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
// MySQL:
|
||
// 1 = Insert
|
||
// 2 = Update durch ON DUPLICATE KEY UPDATE
|
||
// 0 = kein effektives Update, je nach Client/Settings möglich
|
||
return affected == 1, nil
|
||
}
|
||
|
||
func (d *detector) runFirstTimePrivilegedRule(ctx context.Context) error {
|
||
if !d.cfg.UEBAEnabled {
|
||
return nil
|
||
}
|
||
|
||
windowEnd := time.Now().UTC()
|
||
windowStart := windowEnd.Add(-10 * time.Minute)
|
||
|
||
const q = `
|
||
SELECT hostname, subject_user, COUNT(*) AS cnt
|
||
FROM event_logs
|
||
WHERE channel_name = 'Security'
|
||
AND event_id = 4672
|
||
AND ts >= ? AND ts < ?
|
||
AND subject_user <> ''
|
||
AND subject_user <> '-'
|
||
AND subject_user NOT LIKE '%$'
|
||
GROUP BY hostname, subject_user
|
||
`
|
||
|
||
rows, err := d.db.QueryContext(ctx, q, windowStart, windowEnd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var host, user string
|
||
var count int
|
||
|
||
if err := rows.Scan(&host, &user, &count); err != nil {
|
||
return err
|
||
}
|
||
|
||
user = normalizeUsername(user)
|
||
if user == "" || isMachineAccount(user) {
|
||
continue
|
||
}
|
||
|
||
var exists int
|
||
err := d.db.QueryRowContext(ctx, `
|
||
SELECT 1
|
||
FROM user_privilege_baseline
|
||
WHERE username = ?
|
||
LIMIT 1
|
||
`, user).Scan(&exists)
|
||
|
||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return err
|
||
}
|
||
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
created, err := d.insertDetection(ctx, Detection{
|
||
RuleName: "ueba_first_privileged_use",
|
||
Severity: "high",
|
||
Hostname: host,
|
||
Channel: "Security",
|
||
EventID: 4672,
|
||
Score: 6.0,
|
||
Summary: fmt.Sprintf("UEBA: Benutzer %s nutzt erstmals privilegierte Rechte auf Host %s", user, host),
|
||
WindowStart: windowStart,
|
||
WindowEnd: windowEnd,
|
||
Details: mustJSON(map[string]any{
|
||
"user": user,
|
||
"host": host,
|
||
"count": count,
|
||
}),
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if created {
|
||
d.detectionHitsTotal.WithLabelValues("ueba_first_privileged_use", "high").Inc()
|
||
d.anomalyScoreGauge.WithLabelValues(host, "ueba_first_privileged_use").Set(6.0)
|
||
}
|
||
|
||
_, err = d.db.ExecContext(ctx, `
|
||
INSERT INTO user_privilege_baseline
|
||
(username, first_seen, last_seen, seen_count)
|
||
VALUES (?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
last_seen = UTC_TIMESTAMP(6),
|
||
seen_count = seen_count + VALUES(seen_count)
|
||
`, user, count)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
continue
|
||
}
|
||
|
||
_, err = d.db.ExecContext(ctx, `
|
||
UPDATE user_privilege_baseline
|
||
SET last_seen = UTC_TIMESTAMP(6),
|
||
seen_count = seen_count + ?
|
||
WHERE username = ?
|
||
`, count, user)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return rows.Err()
|
||
}
|
||
|
||
func (d *detector) isPrivilegedUser(ctx context.Context, username string) (bool, error) {
|
||
u := normalizeUsername(username)
|
||
if isMachineAccount(u) {
|
||
return false, nil
|
||
}
|
||
|
||
if strings.HasPrefix(u, "adm-") || strings.HasSuffix(u, "-adm") || strings.HasSuffix(u, ".adm") {
|
||
return true, nil
|
||
}
|
||
|
||
var count int
|
||
err := d.db.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM privileged_users
|
||
WHERE enabled = 1
|
||
AND LOWER(username) = ?
|
||
`, u).Scan(&count)
|
||
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
return count > 0, nil
|
||
}
|
||
|
||
func normalizeUsername(username string) string {
|
||
u := strings.ToLower(strings.TrimSpace(username))
|
||
|
||
if strings.Contains(u, `\`) {
|
||
parts := strings.Split(u, `\`)
|
||
u = parts[len(parts)-1]
|
||
}
|
||
|
||
if strings.Contains(u, "@") {
|
||
parts := strings.Split(u, "@")
|
||
u = parts[0]
|
||
}
|
||
|
||
return u
|
||
}
|
||
|
||
func isMachineAccount(username string) bool {
|
||
u := normalizeUsername(username)
|
||
return u == "" || strings.HasSuffix(u, "$")
|
||
}
|
||
|
||
func (d *detector) runAdminNewHostRule(ctx context.Context) error {
|
||
if !d.cfg.UEBAEnabled {
|
||
return nil
|
||
}
|
||
|
||
windowEnd := time.Now().UTC()
|
||
windowStart := windowEnd.Add(-d.cfg.UEBANewContextWindow)
|
||
lookbackStart := windowEnd.Add(-d.cfg.UEBALookback)
|
||
|
||
rows, err := d.db.QueryContext(ctx, `
|
||
SELECT e.hostname, e.target_user, COUNT(*) AS cnt
|
||
FROM event_logs e
|
||
WHERE e.channel_name = 'Security'
|
||
AND e.event_id = 4624
|
||
AND e.ts >= ? AND e.ts < ?
|
||
AND e.target_user <> ''
|
||
AND e.target_user <> '-'
|
||
AND e.target_user NOT LIKE '%$'
|
||
AND NOT EXISTS (
|
||
SELECT 1
|
||
FROM ueba_user_baseline b
|
||
WHERE b.username = e.target_user
|
||
AND b.hostname = e.hostname
|
||
AND b.last_seen >= ?
|
||
AND b.first_seen < ?
|
||
)
|
||
GROUP BY e.hostname, e.target_user
|
||
`, windowStart, windowEnd, lookbackStart, windowStart)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var host, user string
|
||
var count int
|
||
|
||
if err := rows.Scan(&host, &user, &count); err != nil {
|
||
return err
|
||
}
|
||
|
||
privileged, err := d.isPrivilegedUser(ctx, user)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !privileged {
|
||
continue
|
||
}
|
||
|
||
score := 6.0
|
||
severity := "high"
|
||
|
||
if count >= 3 {
|
||
score = 9.0
|
||
severity = "critical"
|
||
}
|
||
|
||
created, err := d.insertDetection(ctx, Detection{
|
||
RuleName: "ueba_admin_new_host",
|
||
Severity: severity,
|
||
Hostname: host,
|
||
Channel: "Security",
|
||
EventID: 4624,
|
||
Score: score,
|
||
WindowStart: windowStart,
|
||
WindowEnd: windowEnd,
|
||
Summary: fmt.Sprintf("UEBA: Privilegierter Benutzer %s meldet sich erstmals auf Host %s an", user, host),
|
||
Details: mustJSON(map[string]any{
|
||
"user": user,
|
||
"host": host,
|
||
"count": count,
|
||
"lookback": d.cfg.UEBALookback.String(),
|
||
"window": d.cfg.UEBANewContextWindow.String(),
|
||
}),
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if created {
|
||
d.detectionHitsTotal.WithLabelValues("ueba_admin_new_host", severity).Inc()
|
||
d.anomalyScoreGauge.WithLabelValues(host, "ueba_admin_new_host").Set(score)
|
||
if d.privilegedNewHostTotal != nil {
|
||
d.privilegedNewHostTotal.WithLabelValues(normalizeUsername(user), host).Inc()
|
||
}
|
||
}
|
||
}
|
||
|
||
return rows.Err()
|
||
}
|
||
|
||
func isNoiseAccount(username string) bool {
|
||
u := normalizeUsername(username)
|
||
|
||
if u == "" || isMachineAccount(u) {
|
||
return true
|
||
}
|
||
|
||
switch u {
|
||
case "system",
|
||
"localsystem",
|
||
"local service",
|
||
"network service",
|
||
"anonymous logon",
|
||
"dwm-1",
|
||
"dwm-2",
|
||
"dwm-3",
|
||
"umfd-0",
|
||
"umfd-1",
|
||
"umfd-2",
|
||
"umfd-3":
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|