feat(go): enrich snapshots with package ownership

This commit is contained in:
Luna 2026-07-22 03:50:55 -07:00
parent 6355fb403d
commit 88d7a6e3b8
No known key found for this signature in database
8 changed files with 149 additions and 54 deletions

View file

@ -116,10 +116,11 @@ the configured `incident_tracking`, `incident_window`, and
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. A 16-job asynchronous worker then
adds streamed SHA-256 values for regular executables up to 8 MiB and file
metadata; saturation leaves fields unknown rather than delaying alerts. Package
ownership, richer integrity anchors, and notifications remain future parity
work. Snapshot text/JSON revisions append synchronized `enodia.hash_chain.v1`
adds streamed SHA-256 values for regular executables up to 8 MiB, file metadata,
and pacman/dpkg/rpm package-owner strings with a two-second query bound;
saturation leaves fields unknown rather than delaying alerts. Richer integrity
anchors and notifications remain future parity work. Snapshot text/JSON
revisions append synchronized `enodia.hash_chain.v1`
records to a private local `hash-chain.jsonl`.
After every successfully emitted status record, the service atomically updates

View file

@ -46,6 +46,10 @@ func TestRunAttachesLiveBaselineLifecycle(t *testing.T) {
cfg := config.Default()
cfg.LogDir = t.TempDir()
cfg.BaselineGrace = 0
// This fixture exercises listener-baseline lifecycle only. Avoid inheriting
// the host's live persistence paths (for example /etc/passwd), whose mtime
// would otherwise add an unrelated environment-dependent alert.
cfg.Detectors["persistence"] = false
calls := 0
capture := func() (model.State, error) {
calls++

View file

@ -47,17 +47,23 @@ type SlowResult struct {
Files map[string]map[string]any
}
func CollectSlow(processes []Process, paths []string) SlowResult {
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)}
result.Processes[process.PID] = map[string]any{"exe_sha256": boundedHash(exe), "package_owner": packageOwner(exe)}
}
for _, path := range paths {
result.Files[path] = fileMetadata(path)
metadata := fileMetadata(path)
metadata["package_owner"] = packageOwner(path)
result.Files[path] = metadata
}
return result
}

View file

@ -3,6 +3,8 @@
package enrich
import (
"os"
"path/filepath"
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
@ -54,3 +56,21 @@ func TestBuildCapsAttackerControlledIndicators(t *testing.T) {
t.Fatal("indicator caps were not applied")
}
}
func TestCollectSlowAddsInjectedPackageOwnership(t *testing.T) {
directory := t.TempDir()
exe := filepath.Join(directory, "payload")
if err := os.WriteFile(exe, []byte("payload"), 0o700); err != nil {
t.Fatal(err)
}
result := CollectSlow([]Process{{PID: 42, Exe: exe}}, []string{exe}, func(path string) string {
if path == exe {
return "example-package 1.0-1"
}
return ""
})
if result.Processes[42]["package_owner"] != "example-package 1.0-1" ||
result.Files[exe]["package_owner"] != "example-package 1.0-1" {
t.Fatalf("result=%#v", result)
}
}

View file

@ -14,57 +14,91 @@ import (
)
var (
mu sync.Mutex
tool *string
cache = map[string]bool{}
toolOnce sync.Once
tool string
cacheMu sync.Mutex
cache = map[string]string{}
seen = map[string]bool{}
)
func packageTool() string {
if tool != nil {
return *tool
}
found := ""
for _, name := range []string{"pacman", "dpkg", "rpm"} {
if _, err := exec.LookPath(name); err == nil {
found = name
break
toolOnce.Do(func() {
for _, name := range []string{"pacman", "dpkg", "rpm"} {
if _, err := exec.LookPath(name); err == nil {
tool = name
break
}
}
}
tool = &found
return found
})
return tool
}
func query(name, path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
func queryOwner(name, path string) string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var output []byte
var err error
switch name {
case "pacman":
output, err := exec.CommandContext(ctx, "pacman", "-Qo", path).Output()
return err == nil && strings.Contains(string(output), "owned by")
output, err = exec.CommandContext(ctx, "pacman", "-Qo", path).Output()
case "dpkg":
output, err := exec.CommandContext(ctx, "dpkg", "-S", path).Output()
return err == nil && strings.Contains(string(output), ":")
output, err = exec.CommandContext(ctx, "dpkg", "-S", path).Output()
case "rpm":
output, err := exec.CommandContext(ctx, "rpm", "-qf", path).Output()
return err == nil && !strings.Contains(string(output), "not owned")
output, err = exec.CommandContext(ctx, "rpm", "-qf", path).Output()
}
return false
if err != nil {
return ""
}
return parseOwner(name, string(output))
}
func parseOwner(name, output string) string {
output = strings.TrimSpace(output)
switch name {
case "pacman":
_, owner, found := strings.Cut(output, " is owned by ")
if found {
return strings.TrimSpace(owner)
}
case "dpkg":
owner, _, found := strings.Cut(output, ":")
if found {
return strings.TrimSpace(owner)
}
case "rpm":
if output != "" && !strings.Contains(strings.ToLower(output), "not owned") {
return output
}
}
return ""
}
// PackageOwner returns the owning installed-package description, or an empty
// string. The command is bounded and runs without holding the cache lock.
func PackageOwner(path string) string {
path = strings.ReplaceAll(path, " (deleted)", "")
if path == "" || path == "(deleted)" {
return ""
}
cacheMu.Lock()
if seen[path] {
owner := cache[path]
cacheMu.Unlock()
return owner
}
cacheMu.Unlock()
owner := ""
if name := packageTool(); name != "" {
owner = queryOwner(name, path)
}
cacheMu.Lock()
cache[path], seen[path] = owner, true
cacheMu.Unlock()
return owner
}
// IsPackageOwned reports whether path belongs to an installed package.
// Results are cached because package ownership is stable within a sweep run.
func IsPackageOwned(path string) bool {
if path == "" || path == "(deleted)" {
return false
}
path = strings.ReplaceAll(path, " (deleted)", "")
mu.Lock()
defer mu.Unlock()
if owned, seen := cache[path]; seen {
return owned
}
name := packageTool()
owned := name != "" && query(name, path)
cache[path] = owned
return owned
return PackageOwner(path) != ""
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package provenance
import "testing"
func TestParseOwnerMatchesSupportedPackageManagers(t *testing.T) {
cases := []struct{ tool, output, want string }{
{"pacman", "/usr/bin/x is owned by core/example 1.2-1", "core/example 1.2-1"},
{"dpkg", "example: /usr/bin/x", "example"},
{"rpm", "example-1.2-1.x86_64", "example-1.2-1.x86_64"},
{"rpm", "file /tmp/x is not owned by any package", ""},
}
for _, tc := range cases {
if got := parseOwner(tc.tool, tc.output); got != tc.want {
t.Fatalf("%s %q -> %q, want %q", tc.tool, tc.output, got, tc.want)
}
}
}

View file

@ -21,6 +21,7 @@ import (
"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"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/provenance"
)
const Schema = "enodia.alert.snapshot.v1"
@ -289,7 +290,7 @@ func (s *Store) applySlowEnrichment(job slowJob) {
}
processes := enrichmentProcesses(report.Processes)
paths := enrichmentPaths(report.Enrichment)
updates := enrich.CollectSlow(processes, paths)
updates := enrich.CollectSlow(processes, paths, provenance.PackageOwner)
// All slow I/O has completed. Take the lock only to merge into the newest
// same-second report, so a concurrent alert capture never loses evidence.
@ -342,6 +343,7 @@ func mergeSlow(report *Report, updates enrich.SlowResult) {
pid, _ := item["pid"].(float64)
if update := updates.Processes[int(pid)]; update != nil {
item["exe_sha256"] = update["exe_sha256"]
item["package_owner"] = nullToNil(update["package_owner"])
}
}
}
@ -354,13 +356,20 @@ func mergeSlow(report *Report, updates enrich.SlowResult) {
path, _ := item["path"].(string)
if update := updates.Files[path]; update != nil {
for key, value := range update {
item[key] = value
item[key] = nullToNil(value)
}
}
}
}
}
func nullToNil(value any) any {
if owner, ok := value.(string); ok && owner == "" {
return nil
}
return value
}
func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail {
pids := alertPIDs(alerts)
result := make([]ProcessDetail, 0, len(pids))