diff --git a/docs/GO_PORT_HANDOFF.md b/docs/GO_PORT_HANDOFF.md index e4a6e1e..a9f79b5 100644 --- a/docs/GO_PORT_HANDOFF.md +++ b/docs/GO_PORT_HANDOFF.md @@ -1,8 +1,8 @@ # Go Port Handoff -Saved: 2026-07-22T02:22:00-07:00 +Saved: 2026-07-22T02:34:00-07:00 Branch: `main` -Base commit: `1ab4add` (`feat(go): chain retained snapshot artifacts`) +Base commit: `eff31b3` (`feat(go): enrich snapshots asynchronously`) Status: implemented and green, but uncommitted ## Worktree warning @@ -10,9 +10,10 @@ Status: implemented and green, but uncommitted 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, and local- - assurance tranches are signed commits `f85c2e8`, `538d1d9`, `fd2bbba`, and - `1ab4add`. The asynchronous-enrichment slice is uncommitted. +- 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. - 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 @@ -104,6 +105,9 @@ static binary. 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. +- `--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. - No live system service was installed or enabled during development. ## Last green verification @@ -150,8 +154,8 @@ the suite still exits successfully. 1. Add bounded package-ownership and integrity-anchor collectors to the existing asynchronous worker without destructive response behavior. -2. Connect the isolated Go snapshot/event state to explicit read-only - management consumers while keeping Python authoritative. +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 default-service or package cutover. diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 3b59598..97d2a65 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -119,6 +119,14 @@ sudo enodia-sentinel-go --events-tail 50 The native reader includes `events.jsonl.1`, preserves chronological order, and fails nonzero if either retained segment contains malformed JSON. +Inspect the sidecar's isolated incident records and timeline without starting +the service or changing Python state: + +```bash +sudo enodia-sentinel-go --incidents-list +sudo enodia-sentinel-go --incident-show +``` + Inspect or remove the validation service with: ```bash diff --git a/go-agent/README.md b/go-agent/README.md index efdcb46..6454075 100644 --- a/go-agent/README.md +++ b/go-agent/README.md @@ -160,6 +160,17 @@ fails nonzero rather than forwarding corrupted evidence. It does not load the agent configuration, so retained evidence remains available while a broken configuration prevents the service from starting. +Inspect the isolated incident index without starting the agent: + +```bash +enodia-sentinel-go --incidents-list --state-dir /tmp/enodia-go-state +enodia-sentinel-go --incident-show inc-20260722-101112-abcd --state-dir /tmp/enodia-go-state +``` + +`--incident-show` returns `enodia.incident.view.v1` with the durable incident, +available snapshot reports, and a time-ordered timeline. These read-only +commands never modify or consult the Python daemon state. + `--fixture`, `--host`, `--timestamp`, `--proc-root`, and `--suid-root` exist for deterministic parity/testing. Fixture mode never activates the live baseline manager. Production work should continue to use the default live `/proc`, diff --git a/go-agent/cmd/enodia-sentinel-go/main.go b/go-agent/cmd/enodia-sentinel-go/main.go index ef319d1..233b579 100644 --- a/go-agent/cmd/enodia-sentinel-go/main.go +++ b/go-agent/cmd/enodia-sentinel-go/main.go @@ -10,8 +10,11 @@ import ( "errors" "flag" "fmt" + "io" "os" "os/signal" + "path/filepath" + "sort" "strings" "sync" "syscall" @@ -25,6 +28,7 @@ import ( "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/eventlog" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/health" + "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/schema" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/sdnotify" @@ -57,6 +61,8 @@ func run() error { rulesList := flag.Bool("rules-list", false, "emit the active event-rule catalog as JSON and exit") rulesShow := flag.Int("rules-show", 0, "emit one event rule by SID as JSON and exit") correlateFile := flag.String("correlate", "", "correlate a JSON array of incident summaries and exit") + incidentsList := flag.Bool("incidents-list", false, "list retained Go incidents as JSON and exit") + incidentShow := flag.String("incident-show", "", "show one retained Go incident timeline as JSON and exit") ebpfExec := flag.Bool("ebpf-exec", false, "enable the native execve eBPF source (fails open to polling)") ebpfSyscall := flag.Bool("ebpf-syscall", false, "enable the native security-syscall eBPF source (fails open to polling)") checkHealth := flag.Bool("health", false, "check the state heartbeat as JSON and exit") @@ -91,6 +97,19 @@ func run() error { } return nil } + if *incidentsList || *incidentShow != "" { + directory := *snapshotDir + if directory == "" { + directory = *stateDir + } + if directory == "" { + directory = "/var/lib/enodia-sentinel-go" + } + if *incidentsList { + return writeIncidentList(directory, os.Stdout) + } + return writeIncidentView(directory, *incidentShow, os.Stdout) + } cfg, err := config.Load(*configPath) if err != nil { @@ -307,6 +326,62 @@ func run() error { return runner.Run(ctx, *once, emit) } +func writeIncidentList(directory string, output io.Writer) error { + index, err := incident.LoadIndex(directory) + if err != nil { + return err + } + items := make([]*incident.Record, 0, len(index)) + for _, item := range index { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { return items[i].LastTS > items[j].LastTS }) + return json.NewEncoder(output).Encode(items) +} + +func writeIncidentView(directory, id string, output io.Writer) error { + index, err := incident.LoadIndex(directory) + if err != nil { + return err + } + item := index[id] + if item == nil { + return fmt.Errorf("no such incident: %s", id) + } + timeline := make([]map[string]any, 0, len(item.Snapshots)) + snapshots := make([]snapshot.Report, 0, len(item.Snapshots)) + for _, name := range item.Snapshots { + path := filepath.Join(directory, strings.TrimSuffix(name, ".log")+".json") + report, err := snapshot.LoadReport(path) + if err != nil { + timeline = append(timeline, map[string]any{"snapshot": name, "time": "?", "missing": true}) + continue + } + snapshots = append(snapshots, report) + signatures := make([]string, 0, len(report.Alerts)) + pids := make([]int, 0, len(report.Processes)) + seen := map[string]bool{} + for _, alert := range report.Alerts { + if !seen[alert.Signature] { + seen[alert.Signature] = true + signatures = append(signatures, alert.Signature) + } + } + for _, process := range report.Processes { + pids = append(pids, process.PID) + } + timeline = append(timeline, map[string]any{ + "snapshot": name, "time": report.Time, "severity": report.Severity, + "signatures": signatures, "pids": pids, + }) + } + sort.Slice(timeline, func(i, j int) bool { return timeline[i]["time"].(string) < timeline[j]["time"].(string) }) + return json.NewEncoder(output).Encode(map[string]any{ + "schema": "enodia.incident.view.v1", "incident": item, + "timeline": timeline, "snapshots": snapshots, + }) +} + func monitorExecSource( ctx context.Context, source execEventReader, diff --git a/go-agent/cmd/enodia-sentinel-go/main_test.go b/go-agent/cmd/enodia-sentinel-go/main_test.go index e61c069..64b9ada 100644 --- a/go-agent/cmd/enodia-sentinel-go/main_test.go +++ b/go-agent/cmd/enodia-sentinel-go/main_test.go @@ -3,9 +3,13 @@ package main import ( + "bytes" "context" + "encoding/json" "fmt" "io" + "os" + "path/filepath" "strings" "syscall" "testing" @@ -15,7 +19,9 @@ import ( "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/ebpfsource" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events" + "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/snapshot" ) type oneExecReader struct { @@ -160,3 +166,42 @@ func TestProbeFailureStatusIsConcise(t *testing.T) { t.Fatalf("unexpected truncated status: %q", got) } } + +func TestIncidentReadOnlyCommandsUseIsolatedSnapshotState(t *testing.T) { + directory := t.TempDir() + store, err := incident.New(directory, true, 1800, 8) + if err != nil { + t.Fatal(err) + } + when := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC) + id, err := store.RecordAlert("alert-1.log", model.Alert{SID: 1, Severity: "HIGH", Signature: "test", PIDs: []int{42}}, map[int]bool{42: true}, when, "host-a", "") + if err != nil { + t.Fatal(err) + } + report := snapshot.Report{Schema: snapshot.Schema, Time: when.Format(time.RFC3339), Host: "host-a", Severity: "HIGH", Alerts: []model.Alert{{SID: 1, Severity: "HIGH", Signature: "test"}}, Processes: []snapshot.ProcessDetail{{PID: 42}}, Enrichment: map[string]any{}} + raw, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "alert-1.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + var list bytes.Buffer + if err := writeIncidentList(directory, &list); err != nil { + t.Fatal(err) + } + if !strings.Contains(list.String(), id) { + t.Fatalf("list=%s", list.String()) + } + var view bytes.Buffer + if err := writeIncidentView(directory, id, &view); err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(view.Bytes(), &decoded); err != nil || decoded["schema"] != "enodia.incident.view.v1" { + t.Fatalf("view=%s err=%v", view.String(), err) + } + if len(decoded["timeline"].([]any)) != 1 || len(decoded["snapshots"].([]any)) != 1 { + t.Fatalf("view=%#v", decoded) + } +} diff --git a/go-agent/internal/snapshot/store.go b/go-agent/internal/snapshot/store.go index 6beb549..81129b7 100644 --- a/go-agent/internal/snapshot/store.go +++ b/go-agent/internal/snapshot/store.go @@ -55,6 +55,22 @@ type Stats struct { LastAlert *string } +// LoadReport reads one retained snapshot for read-only incident views. +func LoadReport(path string) (Report, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Report{}, err + } + var report Report + if err := json.Unmarshal(raw, &report); err != nil { + return Report{}, err + } + if report.Schema != Schema { + return Report{}, fmt.Errorf("unexpected snapshot schema %q", report.Schema) + } + return report, nil +} + type Store struct { Dir string ProcRoot string