feat(go): add bounded hot-path enrichment
This commit is contained in:
parent
538d1d954a
commit
fd2bbba0d7
6 changed files with 282 additions and 30 deletions
166
go-agent/internal/enrich/enrich.go
Normal file
166
go-agent/internal/enrich/enrich.go
Normal 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)", "") }
|
||||
56
go-agent/internal/enrich/enrich_test.go
Normal file
56
go-agent/internal/enrich/enrich_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue