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
|
# 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue