// 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 ( "crypto/sha256" "encoding/hex" "io" "os" "regexp" "sort" "strings" "syscall" "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 MaxHashBytes = int64(8 << 20) ) 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 } // SlowResult contains bounded filesystem enrichment collected away from the // alert-emission path. A file larger than MaxHashBytes is described but not // hashed, avoiding unbounded worker time or memory use. type SlowResult struct { Processes map[int]map[string]any Files map[string]map[string]any } func CollectSlow(processes []Process, paths []string) SlowResult { result := SlowResult{Processes: map[int]map[string]any{}, Files: map[string]map[string]any{}} for _, process := range processes { exe := cleanPath(process.Exe) if exe == "" { continue } result.Processes[process.PID] = map[string]any{"exe_sha256": boundedHash(exe)} } for _, path := range paths { result.Files[path] = fileMetadata(path) } return result } func boundedHash(path string) any { info, err := os.Stat(path) if err != nil || !info.Mode().IsRegular() || info.Size() > MaxHashBytes { return nil } file, err := os.Open(path) if err != nil { return nil } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, io.LimitReader(file, MaxHashBytes+1)); err != nil { return nil } return hex.EncodeToString(hash.Sum(nil)) } func fileMetadata(path string) map[string]any { info, err := os.Lstat(path) if err != nil { return map[string]any{"exists": false, "mode": nil, "uid": nil, "gid": nil} } mode, uid, gid := int(info.Mode().Perm()), -1, -1 if stat, ok := info.Sys().(*syscall.Stat_t); ok { uid, gid = int(stat.Uid), int(stat.Gid) } return map[string]any{"exists": true, "mode": mode, "uid": uid, "gid": gid} } // 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)", "") }