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