diff --git a/docs/GO_PORT_HANDOFF.md b/docs/GO_PORT_HANDOFF.md index 4cf7c81..28dc648 100644 --- a/docs/GO_PORT_HANDOFF.md +++ b/docs/GO_PORT_HANDOFF.md @@ -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 diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index b13c887..b16e941 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -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`; diff --git a/go-agent/README.md b/go-agent/README.md index 5b7b05f..77a7b2b 100644 --- a/go-agent/README.md +++ b/go-agent/README.md @@ -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 diff --git a/go-agent/internal/enrich/enrich.go b/go-agent/internal/enrich/enrich.go new file mode 100644 index 0000000..b612108 --- /dev/null +++ b/go-agent/internal/enrich/enrich.go @@ -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)", "") } diff --git a/go-agent/internal/enrich/enrich_test.go b/go-agent/internal/enrich/enrich_test.go new file mode 100644 index 0000000..e0cb090 --- /dev/null +++ b/go-agent/internal/enrich/enrich_test.go @@ -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") + } +} diff --git a/go-agent/internal/snapshot/store.go b/go-agent/internal/snapshot/store.go index ce58192..530384f 100644 --- a/go-agent/internal/snapshot/store.go +++ b/go-agent/internal/snapshot/store.go @@ -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.