feat(go): enrich snapshots with package ownership
This commit is contained in:
parent
6355fb403d
commit
88d7a6e3b8
8 changed files with 149 additions and 54 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
# Go Port Handoff
|
# Go Port Handoff
|
||||||
|
|
||||||
Saved: 2026-07-22T02:34:00-07:00
|
Saved: 2026-07-22T02:48:00-07:00
|
||||||
Branch: `main`
|
Branch: `main`
|
||||||
Base commit: `eff31b3` (`feat(go): enrich snapshots asynchronously`)
|
Base commit: `6355fb4` (`feat(go): expose retained incident views`)
|
||||||
Status: implemented and green, but uncommitted
|
Status: implemented and green, but uncommitted
|
||||||
|
|
||||||
## Worktree warning
|
## Worktree warning
|
||||||
|
|
@ -11,9 +11,9 @@ The checkout is intentionally dirty and contains work from multiple related
|
||||||
continuations. Do not reset, clean, or broadly restage it.
|
continuations. Do not reset, clean, or broadly restage it.
|
||||||
|
|
||||||
- The validation-sidecar, incident-persistence, bounded-enrichment, local-
|
- The validation-sidecar, incident-persistence, bounded-enrichment, local-
|
||||||
assurance, and asynchronous-enrichment tranches are signed commits
|
assurance, asynchronous-enrichment, and incident-reader tranches are signed
|
||||||
`f85c2e8`, `538d1d9`, `fd2bbba`, `1ab4add`, and `eff31b3`. The native
|
commits `f85c2e8`, `538d1d9`, `fd2bbba`, `1ab4add`, `eff31b3`, and
|
||||||
incident-reader slice is uncommitted.
|
`6355fb4`. The package-ownership enrichment slice is uncommitted.
|
||||||
- At this checkpoint, `git status --short` has 20 entries with untracked
|
- At this checkpoint, `git status --short` has 20 entries with untracked
|
||||||
directories collapsed.
|
directories collapsed.
|
||||||
- The Python GUI files and tests are separate pre-existing work. Preserve them
|
- The Python GUI files and tests are separate pre-existing work. Preserve them
|
||||||
|
|
@ -103,8 +103,10 @@ static binary.
|
||||||
- A single 16-job asynchronous worker now streams SHA-256 only for regular
|
- A single 16-job asynchronous worker now streams SHA-256 only for regular
|
||||||
executables at most 8 MiB and adds lstat file metadata. Slow I/O occurs
|
executables at most 8 MiB and adds lstat file metadata. Slow I/O occurs
|
||||||
outside the snapshot lock; a full queue simply leaves optional fields unknown.
|
outside the snapshot lock; a full queue simply leaves optional fields unknown.
|
||||||
- Package ownership, richer integrity anchors, and notification fan-out are not
|
- The asynchronous worker now resolves pacman/dpkg/rpm package-owner strings
|
||||||
yet ported.
|
with a two-second bound and cache; ownership lookup never holds a detector
|
||||||
|
lock or blocks alert capture.
|
||||||
|
- Richer integrity anchors and notification fan-out are not yet ported.
|
||||||
- `--incidents-list` and `--incident-show <id>` now read the sidecar's isolated
|
- `--incidents-list` and `--incident-show <id>` now read the sidecar's isolated
|
||||||
state without starting the agent. The latter returns `enodia.incident.view.v1`
|
state without starting the agent. The latter returns `enodia.incident.view.v1`
|
||||||
with the incident, available snapshots, and time-ordered timeline.
|
with the incident, available snapshots, and time-ordered timeline.
|
||||||
|
|
@ -152,8 +154,8 @@ the suite still exits successfully.
|
||||||
|
|
||||||
## Resume here
|
## Resume here
|
||||||
|
|
||||||
1. Add bounded package-ownership and integrity-anchor collectors to the existing
|
1. Add bounded integrity-anchor collectors to the existing asynchronous worker
|
||||||
asynchronous worker without destructive response behavior.
|
without destructive response behavior.
|
||||||
2. Add a read-only management API consumer for isolated Go snapshot/event state
|
2. Add a read-only management API consumer for isolated Go snapshot/event state
|
||||||
while keeping Python authoritative.
|
while keeping Python authoritative.
|
||||||
3. Continue broader Phase 3 rule metadata/state parity before considering any
|
3. Continue broader Phase 3 rule metadata/state parity before considering any
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,11 @@ the configured `incident_tracking`, `incident_window`, and
|
||||||
and one snapshot name idempotently. Hot-path enrichment derives bounded
|
and one snapshot name idempotently. Hot-path enrichment derives bounded
|
||||||
process/parent context, lineage, remote-IP classification, and candidate paths
|
process/parent context, lineage, remote-IP classification, and candidate paths
|
||||||
from already-captured data without new I/O. A 16-job asynchronous worker then
|
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
|
adds streamed SHA-256 values for regular executables up to 8 MiB, file metadata,
|
||||||
metadata; saturation leaves fields unknown rather than delaying alerts. Package
|
and pacman/dpkg/rpm package-owner strings with a two-second query bound;
|
||||||
ownership, richer integrity anchors, and notifications remain future parity
|
saturation leaves fields unknown rather than delaying alerts. Richer integrity
|
||||||
work. Snapshot text/JSON revisions append synchronized `enodia.hash_chain.v1`
|
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`.
|
records to a private local `hash-chain.jsonl`.
|
||||||
|
|
||||||
After every successfully emitted status record, the service atomically updates
|
After every successfully emitted status record, the service atomically updates
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ func TestRunAttachesLiveBaselineLifecycle(t *testing.T) {
|
||||||
cfg := config.Default()
|
cfg := config.Default()
|
||||||
cfg.LogDir = t.TempDir()
|
cfg.LogDir = t.TempDir()
|
||||||
cfg.BaselineGrace = 0
|
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
|
calls := 0
|
||||||
capture := func() (model.State, error) {
|
capture := func() (model.State, error) {
|
||||||
calls++
|
calls++
|
||||||
|
|
|
||||||
|
|
@ -47,17 +47,23 @@ type SlowResult struct {
|
||||||
Files map[string]map[string]any
|
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{}}
|
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 {
|
for _, process := range processes {
|
||||||
exe := cleanPath(process.Exe)
|
exe := cleanPath(process.Exe)
|
||||||
if exe == "" {
|
if exe == "" {
|
||||||
continue
|
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 {
|
for _, path := range paths {
|
||||||
result.Files[path] = fileMetadata(path)
|
metadata := fileMetadata(path)
|
||||||
|
metadata["package_owner"] = packageOwner(path)
|
||||||
|
result.Files[path] = metadata
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
package enrich
|
package enrich
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
"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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,57 +14,91 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
toolOnce sync.Once
|
||||||
tool *string
|
tool string
|
||||||
cache = map[string]bool{}
|
cacheMu sync.Mutex
|
||||||
|
cache = map[string]string{}
|
||||||
|
seen = map[string]bool{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func packageTool() string {
|
func packageTool() string {
|
||||||
if tool != nil {
|
toolOnce.Do(func() {
|
||||||
return *tool
|
|
||||||
}
|
|
||||||
found := ""
|
|
||||||
for _, name := range []string{"pacman", "dpkg", "rpm"} {
|
for _, name := range []string{"pacman", "dpkg", "rpm"} {
|
||||||
if _, err := exec.LookPath(name); err == nil {
|
if _, err := exec.LookPath(name); err == nil {
|
||||||
found = name
|
tool = name
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tool = &found
|
})
|
||||||
return found
|
return tool
|
||||||
}
|
}
|
||||||
|
|
||||||
func query(name, path string) bool {
|
func queryOwner(name, path string) string {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
var output []byte
|
||||||
|
var err error
|
||||||
switch name {
|
switch name {
|
||||||
case "pacman":
|
case "pacman":
|
||||||
output, err := exec.CommandContext(ctx, "pacman", "-Qo", path).Output()
|
output, err = exec.CommandContext(ctx, "pacman", "-Qo", path).Output()
|
||||||
return err == nil && strings.Contains(string(output), "owned by")
|
|
||||||
case "dpkg":
|
case "dpkg":
|
||||||
output, err := exec.CommandContext(ctx, "dpkg", "-S", path).Output()
|
output, err = exec.CommandContext(ctx, "dpkg", "-S", path).Output()
|
||||||
return err == nil && strings.Contains(string(output), ":")
|
|
||||||
case "rpm":
|
case "rpm":
|
||||||
output, err := exec.CommandContext(ctx, "rpm", "-qf", path).Output()
|
output, err = exec.CommandContext(ctx, "rpm", "-qf", path).Output()
|
||||||
return err == nil && !strings.Contains(string(output), "not owned")
|
|
||||||
}
|
}
|
||||||
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.
|
// IsPackageOwned reports whether path belongs to an installed package.
|
||||||
// Results are cached because package ownership is stable within a sweep run.
|
// Results are cached because package ownership is stable within a sweep run.
|
||||||
func IsPackageOwned(path string) bool {
|
func IsPackageOwned(path string) bool {
|
||||||
if path == "" || path == "(deleted)" {
|
return PackageOwner(path) != ""
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
go-agent/internal/provenance/provenance_test.go
Normal file
19
go-agent/internal/provenance/provenance_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/enrich"
|
"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/incident"
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||||
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/provenance"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Schema = "enodia.alert.snapshot.v1"
|
const Schema = "enodia.alert.snapshot.v1"
|
||||||
|
|
@ -289,7 +290,7 @@ func (s *Store) applySlowEnrichment(job slowJob) {
|
||||||
}
|
}
|
||||||
processes := enrichmentProcesses(report.Processes)
|
processes := enrichmentProcesses(report.Processes)
|
||||||
paths := enrichmentPaths(report.Enrichment)
|
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
|
// 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.
|
// 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)
|
pid, _ := item["pid"].(float64)
|
||||||
if update := updates.Processes[int(pid)]; update != nil {
|
if update := updates.Processes[int(pid)]; update != nil {
|
||||||
item["exe_sha256"] = update["exe_sha256"]
|
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)
|
path, _ := item["path"].(string)
|
||||||
if update := updates.Files[path]; update != nil {
|
if update := updates.Files[path]; update != nil {
|
||||||
for key, value := range update {
|
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 {
|
func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail {
|
||||||
pids := alertPIDs(alerts)
|
pids := alertPIDs(alerts)
|
||||||
result := make([]ProcessDetail, 0, len(pids))
|
result := make([]ProcessDetail, 0, len(pids))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue