feat(go): expose retained incident views

This commit is contained in:
Luna 2026-07-22 03:12:34 -07:00
parent eff31b3a82
commit 6355fb403d
No known key found for this signature in database
6 changed files with 166 additions and 7 deletions

View file

@ -1,8 +1,8 @@
# Go Port Handoff # Go Port Handoff
Saved: 2026-07-22T02:22:00-07:00 Saved: 2026-07-22T02:34:00-07:00
Branch: `main` 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 Status: implemented and green, but uncommitted
## Worktree warning ## Worktree warning
@ -10,9 +10,10 @@ Status: implemented and green, but uncommitted
The checkout is intentionally dirty and contains work from multiple related 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, and local- - The validation-sidecar, incident-persistence, bounded-enrichment, local-
assurance tranches are signed commits `f85c2e8`, `538d1d9`, `fd2bbba`, and assurance, and asynchronous-enrichment tranches are signed commits
`1ab4add`. The asynchronous-enrichment slice is uncommitted. `f85c2e8`, `538d1d9`, `fd2bbba`, `1ab4add`, and `eff31b3`. The native
incident-reader 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
@ -104,6 +105,9 @@ static binary.
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 - Package ownership, richer integrity anchors, and notification fan-out are not
yet ported. yet ported.
- `--incidents-list` and `--incident-show <id>` 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. - No live system service was installed or enabled during development.
## Last green verification ## Last green verification
@ -150,8 +154,8 @@ the suite still exits successfully.
1. Add bounded package-ownership and integrity-anchor collectors to the existing 1. Add bounded package-ownership and integrity-anchor collectors to the existing
asynchronous worker without destructive response behavior. asynchronous worker without destructive response behavior.
2. Connect the isolated Go snapshot/event state to explicit read-only 2. Add a read-only management API consumer for isolated Go snapshot/event state
management consumers 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
default-service or package cutover. default-service or package cutover.

View file

@ -119,6 +119,14 @@ sudo enodia-sentinel-go --events-tail 50
The native reader includes `events.jsonl.1`, preserves chronological order, and The native reader includes `events.jsonl.1`, preserves chronological order, and
fails nonzero if either retained segment contains malformed JSON. 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 <incident-id>
```
Inspect or remove the validation service with: Inspect or remove the validation service with:
```bash ```bash

View file

@ -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 agent configuration, so retained evidence remains available while a broken
configuration prevents the service from starting. 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 `--fixture`, `--host`, `--timestamp`, `--proc-root`, and `--suid-root` exist for
deterministic parity/testing. Fixture mode never activates the live baseline deterministic parity/testing. Fixture mode never activates the live baseline
manager. Production work should continue to use the default live `/proc`, manager. Production work should continue to use the default live `/proc`,

View file

@ -10,8 +10,11 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@ -25,6 +28,7 @@ import (
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/eventlog" "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/events"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/health" "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/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/sdnotify" "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") 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") 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") 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)") 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)") 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") checkHealth := flag.Bool("health", false, "check the state heartbeat as JSON and exit")
@ -91,6 +97,19 @@ func run() error {
} }
return nil 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) cfg, err := config.Load(*configPath)
if err != nil { if err != nil {
@ -307,6 +326,62 @@ func run() error {
return runner.Run(ctx, *once, emit) 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( func monitorExecSource(
ctx context.Context, ctx context.Context,
source execEventReader, source execEventReader,

View file

@ -3,9 +3,13 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"testing" "testing"
@ -15,7 +19,9 @@ import (
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" "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/ebpfsource"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events" "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/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/snapshot"
) )
type oneExecReader struct { type oneExecReader struct {
@ -160,3 +166,42 @@ func TestProbeFailureStatusIsConcise(t *testing.T) {
t.Fatalf("unexpected truncated status: %q", got) 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)
}
}

View file

@ -55,6 +55,22 @@ type Stats struct {
LastAlert *string 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 { type Store struct {
Dir string Dir string
ProcRoot string ProcRoot string