feat(go): add bounded hot-path enrichment

This commit is contained in:
Luna 2026-07-22 02:03:30 -07:00
parent 538d1d954a
commit fd2bbba0d7
No known key found for this signature in database
6 changed files with 282 additions and 30 deletions

View 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)", "") }

View 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")
}
}

View file

@ -17,6 +17,7 @@ import (
"time"
"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/model"
)
@ -54,14 +55,15 @@ type Stats struct {
}
type Store struct {
Dir string
ProcRoot string
MaxCount int
MaxAgeDays int
Now func() time.Time
Collect func(int) ProcessDetail
ParentOf func(int) int
Incidents *incident.Store
Dir string
ProcRoot string
MaxCount int
MaxAgeDays int
Now func() time.Time
Collect func(int) ProcessDetail
ParentOf func(int) int
Incidents *incident.Store
LineageDepth int
mu sync.Mutex
}
@ -97,7 +99,7 @@ func New(dir, procRoot string, maxCount, maxAgeDays int) (*Store, error) {
if err := os.Remove(probePath); err != nil {
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.ParentOf = func(pid int) int { return readParent(procRoot, pid) }
if err := store.Prune(); err != nil {
@ -114,6 +116,7 @@ func (s *Store) ConfigureIncidents(enabled bool, window, lineageDepth int) error
return err
}
s.Incidents = tracker
s.LineageDepth = lineageDepth
return nil
}
@ -160,9 +163,9 @@ func (s *Store) CaptureEvent(event map[string]any) (string, error) {
report.Alerts = append(report.Alerts, alert)
report.Severity = maxSeverity(report.Severity, alert.Severity)
report.Processes = s.collectProcesses(report.Alerts)
pids := alertPIDs(report.Alerts)
lineage := correlation.LineageOf(pids, s.ParentOf, s.LineageDepth)
if s.Incidents != nil {
pids := alertPIDs(report.Alerts)
lineage := correlation.LineageOf(pids, s.ParentOf, s.Incidents.Depth)
preferred := ""
if report.IncidentID != nil {
preferred = *report.IncidentID
@ -173,9 +176,7 @@ func (s *Store) CaptureEvent(event map[string]any) (string, error) {
report.IncidentID = &id
}
}
if report.Enrichment == nil {
report.Enrichment = map[string]any{"source": "enodia-sentinel-go"}
}
report.Enrichment = enrich.Build(report.Alerts, enrichmentProcesses(report.Processes), lineageValues(lineage))
if err := writeJSONAtomic(jsonPath, report); err != nil {
return "", err
}
@ -212,6 +213,28 @@ func alertPIDs(alerts []model.Alert) []int {
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,
// parseable snapshot files. Calling it for every status emission keeps the age
// bound active even when a quiet host produces no new alert snapshots.