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

@ -1,8 +1,8 @@
# Go Port Handoff
Saved: 2026-07-22T01:58:38-07:00
Saved: 2026-07-22T02:03:00-07:00
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
## Worktree warning
@ -10,8 +10,8 @@ Status: implemented and green, but uncommitted
The checkout is intentionally dirty and contains work from multiple related
continuations. Do not reset, clean, or broadly restage it.
- The completed validation-sidecar tranche was committed as `f85c2e8` before
this continuation. The current Go incident tranche is not yet committed.
- The validation-sidecar and incident-persistence tranches are committed as
`f85c2e8` and `6d5a897`. The bounded-enrichment slice is uncommitted.
- At this checkpoint, `git status --short` has 20 entries with untracked
directories collapsed.
- 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
`list_incidents` and `get_incident` consumers successfully loaded a live
Go-produced index, snapshot, and timeline.
- Enrichment currently contains only a Go-source marker plus process detail;
rich enrichment, assurance chaining, and notification fan-out are not yet
ported.
- A bounded no-I/O enrichment stage now derives Python-shaped process/parent
context, lineage, remote-IP classification, and candidate paths entirely from
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.
## Last green verification
@ -137,9 +140,9 @@ the suite still exits successfully.
## Resume here
1. Port bounded post-alert enrichment and local assurance chaining without
adding destructive response behavior or blocking the event loop on slow
collectors.
1. Move slow enrichment collectors (ownership, bounded hashing, file metadata,
integrity anchors) behind a bounded asynchronous worker, then add local
assurance chaining without destructive response behavior.
2. Connect the isolated Go snapshot/event state to explicit read-only
management consumers while keeping Python authoritative.
3. Continue broader Phase 3 rule metadata/state parity before considering any

View file

@ -93,9 +93,11 @@ Same-second events are merged into one snapshot, required
`max_snapshots` / `max_snapshot_age_days` settings bound retention. The current
sidecar also writes a private atomic `incidents.json` with the required
`enodia.incident.v1` fields, process-lineage/time-window grouping, and additive
correlation evidence. The Go enrichment block is intentionally minimal; Python
remains authoritative for rich enrichment, assurance chaining, notifications,
and management consumers.
correlation evidence. Hot-path Go enrichment is bounded and no-I/O: process and
parent context, lineage, remote classification, and candidate paths come from
already-captured records. Python remains authoritative for ownership/hashes,
file and integrity metadata, assurance chaining, notifications, and management
consumers.
Each successful sweep atomically refreshes
`/var/lib/enodia-sentinel-go/heartbeat` as a Unix timestamp with mode `0600`;

View file

@ -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
the configured `incident_tracking`, `incident_window`, and
`incident_lineage_depth` settings; same-second alert batches update one incident
and one snapshot name idempotently. Enrichment is currently limited to a
Go-source marker plus best-effort process detail; the Python forensic
enrichment/assurance and notification pipelines remain future parity work.
and one snapshot name idempotently. Hot-path enrichment derives bounded
process/parent context, lineage, remote-IP classification, and candidate paths
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
`/var/lib/enodia-sentinel-go/heartbeat` with the current Unix timestamp (mode

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"
)
@ -62,6 +63,7 @@ type Store struct {
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)
if s.Incidents != nil {
pids := alertPIDs(report.Alerts)
lineage := correlation.LineageOf(pids, s.ParentOf, s.Incidents.Depth)
lineage := correlation.LineageOf(pids, s.ParentOf, s.LineageDepth)
if s.Incidents != nil {
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.