feat(go): add bounded hot-path enrichment
This commit is contained in:
parent
538d1d954a
commit
fd2bbba0d7
6 changed files with 282 additions and 30 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
# Go Port Handoff
|
# Go Port Handoff
|
||||||
|
|
||||||
Saved: 2026-07-22T01:58:38-07:00
|
Saved: 2026-07-22T02:03:00-07:00
|
||||||
Branch: `main`
|
Branch: `main`
|
||||||
Base commit: `f85c2e8` (`feat(go): advance validation sidecar toward production`)
|
Base commit: `6d5a897` (`feat(go): persist Python-compatible incidents`)
|
||||||
Status: implemented and green, but uncommitted
|
Status: implemented and green, but uncommitted
|
||||||
|
|
||||||
## Worktree warning
|
## Worktree warning
|
||||||
|
|
@ -10,8 +10,8 @@ Status: implemented and green, but uncommitted
|
||||||
The checkout is intentionally dirty and contains work from multiple related
|
The checkout is intentionally dirty and contains work from multiple related
|
||||||
continuations. Do not reset, clean, or broadly restage it.
|
continuations. Do not reset, clean, or broadly restage it.
|
||||||
|
|
||||||
- The completed validation-sidecar tranche was committed as `f85c2e8` before
|
- The validation-sidecar and incident-persistence tranches are committed as
|
||||||
this continuation. The current Go incident tranche is not yet committed.
|
`f85c2e8` and `6d5a897`. The bounded-enrichment slice is uncommitted.
|
||||||
- At this checkpoint, `git status --short` has 20 entries with untracked
|
- At this checkpoint, `git status --short` has 20 entries with untracked
|
||||||
directories collapsed.
|
directories collapsed.
|
||||||
- The Python GUI files and tests are separate pre-existing work. Preserve them
|
- The Python GUI files and tests are separate pre-existing work. Preserve them
|
||||||
|
|
@ -90,9 +90,12 @@ static binary.
|
||||||
additively and correlation SID `100080` is persisted. Python's unchanged
|
additively and correlation SID `100080` is persisted. Python's unchanged
|
||||||
`list_incidents` and `get_incident` consumers successfully loaded a live
|
`list_incidents` and `get_incident` consumers successfully loaded a live
|
||||||
Go-produced index, snapshot, and timeline.
|
Go-produced index, snapshot, and timeline.
|
||||||
- Enrichment currently contains only a Go-source marker plus process detail;
|
- A bounded no-I/O enrichment stage now derives Python-shaped process/parent
|
||||||
rich enrichment, assurance chaining, and notification fan-out are not yet
|
context, lineage, remote-IP classification, and candidate paths entirely from
|
||||||
ported.
|
already-captured records. Alert/process/indicator caps bound attacker-
|
||||||
|
controlled work on the emission path.
|
||||||
|
- Package ownership, executable hashes, file metadata, integrity anchors,
|
||||||
|
assurance chaining, and notification fan-out are not yet ported.
|
||||||
- No live system service was installed or enabled during development.
|
- No live system service was installed or enabled during development.
|
||||||
|
|
||||||
## Last green verification
|
## Last green verification
|
||||||
|
|
@ -137,9 +140,9 @@ the suite still exits successfully.
|
||||||
|
|
||||||
## Resume here
|
## Resume here
|
||||||
|
|
||||||
1. Port bounded post-alert enrichment and local assurance chaining without
|
1. Move slow enrichment collectors (ownership, bounded hashing, file metadata,
|
||||||
adding destructive response behavior or blocking the event loop on slow
|
integrity anchors) behind a bounded asynchronous worker, then add local
|
||||||
collectors.
|
assurance chaining without destructive response behavior.
|
||||||
2. Connect the isolated Go snapshot/event state to explicit read-only
|
2. Connect the isolated Go snapshot/event state to explicit read-only
|
||||||
management consumers while keeping Python authoritative.
|
management consumers while keeping Python authoritative.
|
||||||
3. Continue broader Phase 3 rule metadata/state parity before considering any
|
3. Continue broader Phase 3 rule metadata/state parity before considering any
|
||||||
|
|
|
||||||
|
|
@ -93,9 +93,11 @@ Same-second events are merged into one snapshot, required
|
||||||
`max_snapshots` / `max_snapshot_age_days` settings bound retention. The current
|
`max_snapshots` / `max_snapshot_age_days` settings bound retention. The current
|
||||||
sidecar also writes a private atomic `incidents.json` with the required
|
sidecar also writes a private atomic `incidents.json` with the required
|
||||||
`enodia.incident.v1` fields, process-lineage/time-window grouping, and additive
|
`enodia.incident.v1` fields, process-lineage/time-window grouping, and additive
|
||||||
correlation evidence. The Go enrichment block is intentionally minimal; Python
|
correlation evidence. Hot-path Go enrichment is bounded and no-I/O: process and
|
||||||
remains authoritative for rich enrichment, assurance chaining, notifications,
|
parent context, lineage, remote classification, and candidate paths come from
|
||||||
and management consumers.
|
already-captured records. Python remains authoritative for ownership/hashes,
|
||||||
|
file and integrity metadata, assurance chaining, notifications, and management
|
||||||
|
consumers.
|
||||||
|
|
||||||
Each successful sweep atomically refreshes
|
Each successful sweep atomically refreshes
|
||||||
`/var/lib/enodia-sentinel-go/heartbeat` as a Unix timestamp with mode `0600`;
|
`/var/lib/enodia-sentinel-go/heartbeat` as a Unix timestamp with mode `0600`;
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,11 @@ counts are populated only from parseable retained snapshots. Each snapshot is
|
||||||
linked to an incident in a private atomic `incidents.json` index. Grouping uses
|
linked to an incident in a private atomic `incidents.json` index. Grouping uses
|
||||||
the configured `incident_tracking`, `incident_window`, and
|
the configured `incident_tracking`, `incident_window`, and
|
||||||
`incident_lineage_depth` settings; same-second alert batches update one incident
|
`incident_lineage_depth` settings; same-second alert batches update one incident
|
||||||
and one snapshot name idempotently. Enrichment is currently limited to a
|
and one snapshot name idempotently. Hot-path enrichment derives bounded
|
||||||
Go-source marker plus best-effort process detail; the Python forensic
|
process/parent context, lineage, remote-IP classification, and candidate paths
|
||||||
enrichment/assurance and notification pipelines remain future parity work.
|
from already-captured data without new I/O. Package ownership, hashes, file
|
||||||
|
metadata, integrity anchors, assurance chaining, and notifications remain
|
||||||
|
future parity work.
|
||||||
|
|
||||||
After every successfully emitted status record, the service atomically updates
|
After every successfully emitted status record, the service atomically updates
|
||||||
`/var/lib/enodia-sentinel-go/heartbeat` with the current Unix timestamp (mode
|
`/var/lib/enodia-sentinel-go/heartbeat` with the current Unix timestamp (mode
|
||||||
|
|
|
||||||
166
go-agent/internal/enrich/enrich.go
Normal file
166
go-agent/internal/enrich/enrich.go
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package enrich builds bounded, no-I/O post-alert context from data already
|
||||||
|
// captured for a snapshot. Filesystem hashing, package ownership, and integrity
|
||||||
|
// collectors are intentionally excluded from this hot-path stage.
|
||||||
|
package enrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||||
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxAlerts = 128
|
||||||
|
maxProcesses = 64
|
||||||
|
maxIndicators = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ipv4Pattern = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
|
||||||
|
pathPattern = regexp.MustCompile(`/[^\s,\])]+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Process struct {
|
||||||
|
PID int
|
||||||
|
Comm string
|
||||||
|
Exe string
|
||||||
|
PPID int
|
||||||
|
PPIDComm string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build returns the stable Python enrichment keys without invoking any slow
|
||||||
|
// collectors. Input and indicator caps keep attacker-controlled details from
|
||||||
|
// causing unbounded snapshot work or output.
|
||||||
|
func Build(alerts []model.Alert, processes []Process, lineage []int) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"source": "enodia-sentinel-go",
|
||||||
|
"processes": processContext(processes),
|
||||||
|
"remotes": remoteContext(alerts),
|
||||||
|
"files": fileCandidates(alerts),
|
||||||
|
"recent_writes": []string{},
|
||||||
|
"lineage": lineageContext(processes, lineage),
|
||||||
|
"integrity": map[string]any{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processContext(processes []Process) []map[string]any {
|
||||||
|
if len(processes) > maxProcesses {
|
||||||
|
processes = processes[:maxProcesses]
|
||||||
|
}
|
||||||
|
result := make([]map[string]any, 0, len(processes))
|
||||||
|
for _, process := range processes {
|
||||||
|
chain := []map[string]any{}
|
||||||
|
if process.PPID > 0 {
|
||||||
|
chain = append(chain, map[string]any{
|
||||||
|
"pid": process.PPID, "comm": process.PPIDComm, "ppid": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result = append(result, map[string]any{
|
||||||
|
"pid": process.PID, "comm": process.Comm,
|
||||||
|
"exe": cleanPath(process.Exe), "package_owner": nil,
|
||||||
|
"exe_sha256": nil, "parent_chain": chain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteContext(alerts []model.Alert) []map[string]any {
|
||||||
|
values := map[string]bool{}
|
||||||
|
for _, alert := range cappedAlerts(alerts) {
|
||||||
|
for _, value := range ipv4Pattern.FindAllString(alert.Detail, -1) {
|
||||||
|
if len(values) >= maxIndicators {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values[value] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := sortedKeys(values)
|
||||||
|
result := make([]map[string]any, 0, len(keys))
|
||||||
|
for _, value := range keys {
|
||||||
|
classification := "reserved"
|
||||||
|
address, ok := netutil.ParseAddr(value)
|
||||||
|
if !ok {
|
||||||
|
classification = "invalid"
|
||||||
|
} else if netutil.IsPublicIP(value) {
|
||||||
|
classification = "public"
|
||||||
|
} else if address.IsLoopback() {
|
||||||
|
classification = "loopback"
|
||||||
|
} else if address.IsPrivate() {
|
||||||
|
classification = "private"
|
||||||
|
} else if address.IsLinkLocalUnicast() {
|
||||||
|
classification = "link-local"
|
||||||
|
}
|
||||||
|
result = append(result, map[string]any{"ip": value, "classification": classification})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileCandidates(alerts []model.Alert) []map[string]any {
|
||||||
|
values := map[string]bool{}
|
||||||
|
for _, alert := range cappedAlerts(alerts) {
|
||||||
|
for _, text := range []string{alert.Detail, alert.Key} {
|
||||||
|
for _, value := range pathPattern.FindAllString(text, -1) {
|
||||||
|
if len(values) >= maxIndicators {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values[cleanPath(strings.TrimSuffix(value, ":"))] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := sortedKeys(values)
|
||||||
|
result := make([]map[string]any, 0, len(keys))
|
||||||
|
for _, value := range keys {
|
||||||
|
// Existence/ownership is unknown until the deferred collector runs.
|
||||||
|
result = append(result, map[string]any{
|
||||||
|
"path": value, "exists": nil, "mode": nil, "uid": nil,
|
||||||
|
"gid": nil, "package_owner": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineageContext(processes []Process, lineage []int) []map[string]any {
|
||||||
|
known := map[int]string{}
|
||||||
|
for _, process := range processes {
|
||||||
|
known[process.PID] = process.Comm
|
||||||
|
if process.PPID > 0 && known[process.PPID] == "" {
|
||||||
|
known[process.PPID] = process.PPIDComm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values := append([]int(nil), lineage...)
|
||||||
|
sort.Ints(values)
|
||||||
|
if len(values) > maxProcesses*4 {
|
||||||
|
values = values[:maxProcesses*4]
|
||||||
|
}
|
||||||
|
result := make([]map[string]any, 0, len(values))
|
||||||
|
for _, pid := range values {
|
||||||
|
comm, present := known[pid]
|
||||||
|
result = append(result, map[string]any{"pid": pid, "comm": comm, "present": present})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cappedAlerts(alerts []model.Alert) []model.Alert {
|
||||||
|
if len(alerts) > maxAlerts {
|
||||||
|
return alerts[:maxAlerts]
|
||||||
|
}
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(values map[string]bool) []string {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for value := range values {
|
||||||
|
if value != "" {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanPath(value string) string { return strings.ReplaceAll(value, " (deleted)", "") }
|
||||||
56
go-agent/internal/enrich/enrich_test.go
Normal file
56
go-agent/internal/enrich/enrich_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package enrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildAddsBoundedProcessRemotePathAndLineageContext(t *testing.T) {
|
||||||
|
result := Build([]model.Alert{{
|
||||||
|
SID: 100010, Signature: "reverse_shell", Key: "persist:/etc/cron.d/dropper",
|
||||||
|
Detail: "pid=42 peer=[8.8.8.8:4444] wrote /tmp/dropper (deleted)",
|
||||||
|
}}, []Process{{PID: 42, Comm: "bash", Exe: "/tmp/dropper (deleted)", PPID: 10, PPIDComm: "nginx"}}, []int{42, 10})
|
||||||
|
processes := result["processes"].([]map[string]any)
|
||||||
|
if len(processes) != 1 || processes[0]["exe"] != "/tmp/dropper" ||
|
||||||
|
processes[0]["package_owner"] != nil || processes[0]["exe_sha256"] != nil {
|
||||||
|
t.Fatalf("processes=%#v", processes)
|
||||||
|
}
|
||||||
|
chain := processes[0]["parent_chain"].([]map[string]any)
|
||||||
|
if len(chain) != 1 || chain[0]["pid"] != 10 || chain[0]["comm"] != "nginx" {
|
||||||
|
t.Fatalf("chain=%#v", chain)
|
||||||
|
}
|
||||||
|
remotes := result["remotes"].([]map[string]any)
|
||||||
|
if len(remotes) != 1 || remotes[0]["ip"] != "8.8.8.8" || remotes[0]["classification"] != "public" {
|
||||||
|
t.Fatalf("remotes=%#v", remotes)
|
||||||
|
}
|
||||||
|
files := result["files"].([]map[string]any)
|
||||||
|
if len(files) != 2 || files[0]["path"] != "/etc/cron.d/dropper" || files[1]["path"] != "/tmp/dropper" {
|
||||||
|
t.Fatalf("files=%#v", files)
|
||||||
|
}
|
||||||
|
lineage := result["lineage"].([]map[string]any)
|
||||||
|
if len(lineage) != 2 || lineage[0]["pid"] != 10 || lineage[0]["comm"] != "nginx" {
|
||||||
|
t.Fatalf("lineage=%#v", lineage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildClassifiesPrivateAndInvalidAddresses(t *testing.T) {
|
||||||
|
result := Build([]model.Alert{{Detail: "peers 10.1.2.3 and 999.1.2.3"}}, nil, nil)
|
||||||
|
remotes := result["remotes"].([]map[string]any)
|
||||||
|
if len(remotes) != 2 || remotes[0]["classification"] != "private" || remotes[1]["classification"] != "invalid" {
|
||||||
|
t.Fatalf("remotes=%#v", remotes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCapsAttackerControlledIndicators(t *testing.T) {
|
||||||
|
alerts := make([]model.Alert, 300)
|
||||||
|
for index := range alerts {
|
||||||
|
alerts[index].Detail = "/tmp/path " + "1.2.3.4"
|
||||||
|
}
|
||||||
|
result := Build(alerts, nil, nil)
|
||||||
|
if len(result["remotes"].([]map[string]any)) > maxIndicators || len(result["files"].([]map[string]any)) > maxIndicators {
|
||||||
|
t.Fatal("indicator caps were not applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation"
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation"
|
||||||
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/enrich"
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||||
)
|
)
|
||||||
|
|
@ -54,14 +55,15 @@ type Stats struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
Dir string
|
Dir string
|
||||||
ProcRoot string
|
ProcRoot string
|
||||||
MaxCount int
|
MaxCount int
|
||||||
MaxAgeDays int
|
MaxAgeDays int
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
Collect func(int) ProcessDetail
|
Collect func(int) ProcessDetail
|
||||||
ParentOf func(int) int
|
ParentOf func(int) int
|
||||||
Incidents *incident.Store
|
Incidents *incident.Store
|
||||||
|
LineageDepth int
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +99,7 @@ func New(dir, procRoot string, maxCount, maxAgeDays int) (*Store, error) {
|
||||||
if err := os.Remove(probePath); err != nil {
|
if err := os.Remove(probePath); err != nil {
|
||||||
return nil, fmt.Errorf("remove snapshot preflight: %w", err)
|
return nil, fmt.Errorf("remove snapshot preflight: %w", err)
|
||||||
}
|
}
|
||||||
store := &Store{Dir: dir, ProcRoot: procRoot, MaxCount: maxCount, MaxAgeDays: maxAgeDays, Now: time.Now}
|
store := &Store{Dir: dir, ProcRoot: procRoot, MaxCount: maxCount, MaxAgeDays: maxAgeDays, Now: time.Now, LineageDepth: 8}
|
||||||
store.Collect = func(pid int) ProcessDetail { return collectProcess(procRoot, pid) }
|
store.Collect = func(pid int) ProcessDetail { return collectProcess(procRoot, pid) }
|
||||||
store.ParentOf = func(pid int) int { return readParent(procRoot, pid) }
|
store.ParentOf = func(pid int) int { return readParent(procRoot, pid) }
|
||||||
if err := store.Prune(); err != nil {
|
if err := store.Prune(); err != nil {
|
||||||
|
|
@ -114,6 +116,7 @@ func (s *Store) ConfigureIncidents(enabled bool, window, lineageDepth int) error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.Incidents = tracker
|
s.Incidents = tracker
|
||||||
|
s.LineageDepth = lineageDepth
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,9 +163,9 @@ func (s *Store) CaptureEvent(event map[string]any) (string, error) {
|
||||||
report.Alerts = append(report.Alerts, alert)
|
report.Alerts = append(report.Alerts, alert)
|
||||||
report.Severity = maxSeverity(report.Severity, alert.Severity)
|
report.Severity = maxSeverity(report.Severity, alert.Severity)
|
||||||
report.Processes = s.collectProcesses(report.Alerts)
|
report.Processes = s.collectProcesses(report.Alerts)
|
||||||
|
pids := alertPIDs(report.Alerts)
|
||||||
|
lineage := correlation.LineageOf(pids, s.ParentOf, s.LineageDepth)
|
||||||
if s.Incidents != nil {
|
if s.Incidents != nil {
|
||||||
pids := alertPIDs(report.Alerts)
|
|
||||||
lineage := correlation.LineageOf(pids, s.ParentOf, s.Incidents.Depth)
|
|
||||||
preferred := ""
|
preferred := ""
|
||||||
if report.IncidentID != nil {
|
if report.IncidentID != nil {
|
||||||
preferred = *report.IncidentID
|
preferred = *report.IncidentID
|
||||||
|
|
@ -173,9 +176,7 @@ func (s *Store) CaptureEvent(event map[string]any) (string, error) {
|
||||||
report.IncidentID = &id
|
report.IncidentID = &id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if report.Enrichment == nil {
|
report.Enrichment = enrich.Build(report.Alerts, enrichmentProcesses(report.Processes), lineageValues(lineage))
|
||||||
report.Enrichment = map[string]any{"source": "enodia-sentinel-go"}
|
|
||||||
}
|
|
||||||
if err := writeJSONAtomic(jsonPath, report); err != nil {
|
if err := writeJSONAtomic(jsonPath, report); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +213,28 @@ func alertPIDs(alerts []model.Alert) []int {
|
||||||
return pids
|
return pids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enrichmentProcesses(processes []ProcessDetail) []enrich.Process {
|
||||||
|
result := make([]enrich.Process, 0, len(processes))
|
||||||
|
for _, process := range processes {
|
||||||
|
result = append(result, enrich.Process{
|
||||||
|
PID: process.PID, Comm: process.Comm, Exe: process.Exe,
|
||||||
|
PPID: process.PPID, PPIDComm: process.PPIDComm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineageValues(lineage map[int]bool) []int {
|
||||||
|
result := make([]int, 0, len(lineage))
|
||||||
|
for pid, included := range lineage {
|
||||||
|
if included {
|
||||||
|
result = append(result, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// SnapshotStats prunes first, then derives status claims from retained,
|
// SnapshotStats prunes first, then derives status claims from retained,
|
||||||
// parseable snapshot files. Calling it for every status emission keeps the age
|
// parseable snapshot files. Calling it for every status emission keeps the age
|
||||||
// bound active even when a quiet host produces no new alert snapshots.
|
// bound active even when a quiet host produces no new alert snapshots.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue