258 lines
7.4 KiB
Go
258 lines
7.4 KiB
Go
// 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"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"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
|
|
Integrity map[string]any
|
|
}
|
|
|
|
func CollectSlow(processes []Process, paths []string, owner ...func(string) string) SlowResult {
|
|
result := SlowResult{Processes: map[int]map[string]any{}, Files: map[string]map[string]any{}}
|
|
packageOwner := func(string) string { return "" }
|
|
if len(owner) > 0 && owner[0] != nil {
|
|
packageOwner = owner[0]
|
|
}
|
|
for _, process := range processes {
|
|
exe := cleanPath(process.Exe)
|
|
if exe == "" {
|
|
continue
|
|
}
|
|
result.Processes[process.PID] = map[string]any{"exe_sha256": boundedHash(exe), "package_owner": packageOwner(exe)}
|
|
}
|
|
for _, path := range paths {
|
|
metadata := fileMetadata(path)
|
|
metadata["package_owner"] = packageOwner(path)
|
|
result.Files[path] = metadata
|
|
}
|
|
return result
|
|
}
|
|
|
|
// IntegrityAnchors reports durable state presence without running FIM, package
|
|
// verification, or rootcheck on the alert path. Those engines stay disabled
|
|
// until their own Go parity tranches land.
|
|
func IntegrityAnchors(directory string) map[string]any {
|
|
return map[string]any{
|
|
"fim_baseline": pathStatus(filepath.Join(directory, "fim-baseline.json")),
|
|
"pkgdb_anchor": pathStatus(filepath.Join(directory, "pkgdb-anchor.json")),
|
|
"package_verify": map[string]any{"enabled": false, "sample": 0},
|
|
"rootcheck": map[string]any{"enabled": false, "interval": 0},
|
|
"go_assurance": pathStatus(filepath.Join(directory, "hash-chain.jsonl")),
|
|
}
|
|
}
|
|
|
|
func pathStatus(path string) map[string]any {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return map[string]any{"path": path, "status": "missing", "age": nil}
|
|
}
|
|
age := int64(time.Since(info.ModTime()).Seconds())
|
|
if age < 0 {
|
|
age = 0
|
|
}
|
|
return map[string]any{"path": path, "status": "present", "age": age}
|
|
}
|
|
|
|
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)", "") }
|