diff --git a/docs/GO_PORT_HANDOFF.md b/docs/GO_PORT_HANDOFF.md index a9f79b5..a019a15 100644 --- a/docs/GO_PORT_HANDOFF.md +++ b/docs/GO_PORT_HANDOFF.md @@ -1,8 +1,8 @@ # Go Port Handoff -Saved: 2026-07-22T02:34:00-07:00 +Saved: 2026-07-22T02:48:00-07:00 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 ## 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. - The validation-sidecar, incident-persistence, bounded-enrichment, local- - assurance, and asynchronous-enrichment tranches are signed commits - `f85c2e8`, `538d1d9`, `fd2bbba`, `1ab4add`, and `eff31b3`. The native - incident-reader slice is uncommitted. + assurance, asynchronous-enrichment, and incident-reader tranches are signed + commits `f85c2e8`, `538d1d9`, `fd2bbba`, `1ab4add`, `eff31b3`, and + `6355fb4`. The package-ownership enrichment slice is uncommitted. - At this checkpoint, `git status --short` has 20 entries with untracked directories collapsed. - 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 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. -- Package ownership, richer integrity anchors, and notification fan-out are not - yet ported. +- The asynchronous worker now resolves pacman/dpkg/rpm package-owner strings + 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 ` now read the sidecar's isolated state without starting the agent. The latter returns `enodia.incident.view.v1` with the incident, available snapshots, and time-ordered timeline. @@ -152,8 +154,8 @@ the suite still exits successfully. ## Resume here -1. Add bounded package-ownership and integrity-anchor collectors to the existing - asynchronous worker without destructive response behavior. +1. Add bounded integrity-anchor collectors to the existing asynchronous worker + without destructive response behavior. 2. Add a read-only management API consumer for isolated Go snapshot/event state while keeping Python authoritative. 3. Continue broader Phase 3 rule metadata/state parity before considering any diff --git a/go-agent/README.md b/go-agent/README.md index 6454075..14362c2 100644 --- a/go-agent/README.md +++ b/go-agent/README.md @@ -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 diff --git a/go-agent/internal/agent/agent_test.go b/go-agent/internal/agent/agent_test.go index 5cafd50..975afaf 100644 --- a/go-agent/internal/agent/agent_test.go +++ b/go-agent/internal/agent/agent_test.go @@ -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++ diff --git a/go-agent/internal/enrich/enrich.go b/go-agent/internal/enrich/enrich.go index cb8cac8..35f447c 100644 --- a/go-agent/internal/enrich/enrich.go +++ b/go-agent/internal/enrich/enrich.go @@ -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 } diff --git a/go-agent/internal/enrich/enrich_test.go b/go-agent/internal/enrich/enrich_test.go index e0cb090..df285e2 100644 --- a/go-agent/internal/enrich/enrich_test.go +++ b/go-agent/internal/enrich/enrich_test.go @@ -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) + } +} diff --git a/go-agent/internal/provenance/provenance.go b/go-agent/internal/provenance/provenance.go index 9c43d22..721af84 100644 --- a/go-agent/internal/provenance/provenance.go +++ b/go-agent/internal/provenance/provenance.go @@ -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) != "" } diff --git a/go-agent/internal/provenance/provenance_test.go b/go-agent/internal/provenance/provenance_test.go new file mode 100644 index 0000000..cf270ff --- /dev/null +++ b/go-agent/internal/provenance/provenance_test.go @@ -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) + } + } +} diff --git a/go-agent/internal/snapshot/store.go b/go-agent/internal/snapshot/store.go index 81129b7..2f7513a 100644 --- a/go-agent/internal/snapshot/store.go +++ b/go-agent/internal/snapshot/store.go @@ -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))