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