feat(go): expose retained incident views
This commit is contained in:
parent
eff31b3a82
commit
6355fb403d
6 changed files with 166 additions and 7 deletions
|
|
@ -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 <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.
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <incident-id>
|
||||
```
|
||||
|
||||
Inspect or remove the validation service with:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue