feat(go): add Phase 1 parity sidecar

This commit is contained in:
Luna 2026-07-10 05:02:50 -07:00
parent 65f5be6420
commit f02509aab5
22 changed files with 947 additions and 6 deletions

35
go-agent/README.md Normal file
View file

@ -0,0 +1,35 @@
# Enodia Sentinel Go Agent (Phase 1 Prototype)
This directory is the parallel Go implementation described in
`docs/SURICATA_ASSIMILATION.md`. Python remains the production agent and the
behavioral oracle until every subsystem reaches fixture and red-team parity.
Current scope:
- standard-library-only flat TOML loading for the settings used so far;
- an injectable `/proc` process snapshot boundary;
- a cancellable sweep loop that emits JSONL `enodia.event.v1` records;
- exact `enodia.alert.v1` parity for the `deleted_exe` detector (SID `100012`);
- `enodia.status.v1` heartbeat records with no persistence or retained-alert
claims;
- a shared fixture checked against the Python detector by
`scripts/check-go-parity.py`.
It does not install a service, write Python state, capture eBPF events, retain
snapshots, or replace `enodia-sentinel`. Run a single terminal-visible sweep:
```bash
cd go-agent
GOCACHE=/tmp/enodia-go-cache go run ./cmd/enodia-sentinel-go --once
```
Development verification from the repository root:
```bash
make test-go
make parity-go
```
`--fixture`, `--host`, `--timestamp`, and `--proc-root` exist for deterministic
parity/testing. Production work should continue to use the default live
`/proc`, hostname, and local timestamp.

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// enodia-sentinel-go is the experimental Phase 1 sidecar. Python remains the
// production implementation and behavioral oracle until parity is complete.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/agent"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/system"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "enodia-sentinel-go:", err)
os.Exit(1)
}
}
func run() error {
once := flag.Bool("once", false, "emit one sweep and exit")
configPath := flag.String("config", "", "Sentinel TOML path (default: ENODIA_CONFIG or /etc/enodia-sentinel.toml)")
procRoot := flag.String("proc-root", "/proc", "procfs root")
fixture := flag.String("fixture", "", "JSON SystemState fixture (parity/testing only)")
host := flag.String("host", "", "override hostname (parity/testing only)")
timestamp := flag.String("timestamp", "", "override RFC3339 timestamp (parity/testing only)")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
capture := func() (model.State, error) { return system.Capture(*procRoot) }
if *fixture != "" {
capture = func() (model.State, error) {
var state model.State
raw, err := os.ReadFile(*fixture)
if err != nil {
return state, err
}
err = json.Unmarshal(raw, &state)
return state, err
}
}
runner := agent.New(cfg, capture)
if *host != "" {
runner.Host = func() (string, error) { return *host, nil }
}
if *timestamp != "" {
fixed, err := time.Parse(time.RFC3339Nano, *timestamp)
if err != nil {
return fmt.Errorf("timestamp: %w", err)
}
runner.Now = func() time.Time { return fixed }
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
return runner.Run(ctx, *once, func(event map[string]any) error {
return encoder.Encode(event)
})
}

3
go-agent/go.mod Normal file
View file

@ -0,0 +1,3 @@
module codeberg.org/anassaeneroi/enodia-sentinal/go-agent
go 1.23

View file

@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package agent owns the parallel Go sweep loop. It emits JSON-compatible
// records but does not write Python daemon state or replace the production
// service during Phase 1.
package agent
import (
"context"
"fmt"
"os"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/detectors"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
)
const Version = "0.7.0"
// CaptureFunc returns one injectable SystemState-equivalent sweep.
type CaptureFunc func() (model.State, error)
// EmitFunc receives one enodia.event.v1-compatible record.
type EmitFunc func(map[string]any) error
// Agent is the Phase 1 sidecar sweep loop.
type Agent struct {
Config config.Config
Capture CaptureFunc
Host func() (string, error)
Now func() time.Time
}
// New builds an agent with production clock and hostname providers.
func New(cfg config.Config, capture CaptureFunc) *Agent {
return &Agent{
Config: cfg,
Capture: capture,
Host: os.Hostname,
Now: time.Now,
}
}
// Sweep captures state once and returns alert events followed by one status
// event. Retained snapshot fields remain empty until persistence is ported.
func (a *Agent) Sweep() ([]map[string]any, error) {
state, err := a.Capture()
if err != nil {
return nil, err
}
host, err := a.Host()
if err != nil {
return nil, err
}
timestamp := a.Now().Local().Format(time.RFC3339Nano)
alerts := make([]model.Alert, 0)
if a.Config.Enabled("deleted_exe") {
alerts = append(alerts, detectors.DeletedExe(state)...)
}
events := make([]map[string]any, 0, len(alerts)+1)
for _, alert := range alerts {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
return nil, err
}
events = append(events, record)
}
status := schema.Status{
Schema: schema.StatusV1,
Version: Version,
Running: true,
TotalAlerts: 0,
Counts: map[string]int{},
LastAlert: nil,
EBPF: "unknown",
EBPFExec: "unknown",
EBPFSyscall: "unknown",
Host: host,
}
record, err := schema.Build("status", status, host, timestamp)
if err != nil {
return nil, err
}
return append(events, record), nil
}
// Run emits one sweep immediately and then waits for each configured interval.
// once is used by parity checks and operator-visible smoke tests.
func (a *Agent) Run(ctx context.Context, once bool, emit EmitFunc) error {
if a.Capture == nil || emit == nil {
return fmt.Errorf("capture and emit functions are required")
}
for {
events, err := a.Sweep()
if err != nil {
return err
}
for _, event := range events {
if err := emit(event); err != nil {
return err
}
}
if once {
return nil
}
timer := time.NewTimer(a.Config.SampleInterval)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return nil
case <-timer.C:
}
}
}

View file

@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package agent
import (
"context"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestRunOnceEmitsAlertAndStatusEnvelopes(t *testing.T) {
agent := New(config.Default(), func() (model.State, error) {
return model.State{Processes: []model.Process{
{PID: 42, Comm: "dropper", Exe: "/tmp/dropper (deleted)"},
}}, nil
})
agent.Host = func() (string, error) { return "host-a", nil }
agent.Now = func() time.Time {
return time.Date(2026, 7, 10, 0, 0, 0, 0, time.FixedZone("PDT", -7*3600))
}
var events []map[string]any
err := agent.Run(context.Background(), true, func(event map[string]any) error {
events = append(events, event)
return nil
})
if err != nil {
t.Fatal(err)
}
if len(events) != 2 || events[0]["event_type"] != "alert" || events[1]["event_type"] != "status" {
t.Fatalf("unexpected events: %#v", events)
}
alert := events[0]["alert"].(model.Alert)
if alert.SID != 100012 || alert.Detail != "pid=42 comm=dropper exe=[/tmp/dropper (deleted)]" {
t.Fatalf("unexpected alert payload: %#v", alert)
}
}
func TestDisabledDetectorEmitsStatusOnly(t *testing.T) {
cfg := config.Default()
cfg.Detectors = map[string]bool{}
agent := New(cfg, func() (model.State, error) {
return model.State{Processes: []model.Process{{PID: 42, Exe: "/tmp/x (deleted)"}}}, nil
})
events, err := agent.Sweep()
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0]["event_type"] != "status" {
t.Fatalf("unexpected events: %#v", events)
}
}

View file

@ -0,0 +1,169 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package config loads the flat subset of enodia-sentinel.toml needed by the
// parallel Go agent. It intentionally uses only the Go standard library.
package config
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"time"
)
var defaultDetectors = []string{
"reverse_shell", "ld_preload", "deleted_exe", "input_snooper",
"credential_access", "stealth_network", "memory_obfuscation",
"first_seen", "new_listener", "new_suid", "persistence", "egress",
}
// Config mirrors the Phase 1 settings consumed by the Go sweep loop. More
// fields are added as their detectors are ported.
type Config struct {
SampleInterval time.Duration
HeartbeatMaxAge int
Detectors map[string]bool
}
// Default returns Python-compatible defaults for the fields currently used.
func Default() Config {
detectors := make(map[string]bool, len(defaultDetectors))
for _, name := range defaultDetectors {
detectors[name] = true
}
return Config{
SampleInterval: 4 * time.Second,
HeartbeatMaxAge: 120,
Detectors: detectors,
}
}
// Enabled reports whether a detector is enabled by configuration.
func (c Config) Enabled(name string) bool {
return c.Detectors[name]
}
// Load reads a flat Sentinel TOML file. Missing files keep defaults, matching
// Python Config.load. Unknown keys are ignored for forward compatibility.
func Load(path string) (Config, error) {
cfg := Default()
if path == "" {
path = os.Getenv("ENODIA_CONFIG")
}
if path == "" {
path = "/etc/enodia-sentinel.toml"
}
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return cfg, nil
}
if err != nil {
return Config{}, err
}
for _, assignment := range assignments(string(raw)) {
key, value, ok := strings.Cut(assignment, "=")
if !ok {
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
switch key {
case "sample_interval":
seconds, err := strconv.ParseFloat(value, 64)
if err != nil || seconds <= 0 {
return Config{}, fmt.Errorf("sample_interval must be positive: %q", value)
}
cfg.SampleInterval = time.Duration(seconds * float64(time.Second))
case "heartbeat_max_age":
age, err := strconv.Atoi(value)
if err != nil || age < 0 {
return Config{}, fmt.Errorf("heartbeat_max_age must be non-negative: %q", value)
}
cfg.HeartbeatMaxAge = age
case "detectors":
names, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("detectors: %w", err)
}
cfg.Detectors = make(map[string]bool, len(names))
for _, name := range names {
cfg.Detectors[name] = true
}
}
}
return cfg, nil
}
func assignments(text string) []string {
var out []string
var current strings.Builder
depth := 0
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(stripComment(raw))
if line == "" {
continue
}
if current.Len() > 0 {
current.WriteByte(' ')
}
current.WriteString(line)
depth += strings.Count(line, "[") - strings.Count(line, "]")
if depth <= 0 {
out = append(out, current.String())
current.Reset()
depth = 0
}
}
if current.Len() > 0 {
out = append(out, current.String())
}
return out
}
func stripComment(line string) string {
inString, escaped := false, false
for i, r := range line {
if escaped {
escaped = false
continue
}
if r == '\\' && inString {
escaped = true
continue
}
if r == '"' {
inString = !inString
continue
}
if r == '#' && !inString {
return line[:i]
}
}
return line
}
func stringArray(value string) ([]string, error) {
value = strings.TrimSpace(value)
if len(value) < 2 || value[0] != '[' || value[len(value)-1] != ']' {
return nil, fmt.Errorf("expected array, got %q", value)
}
body := strings.TrimSpace(value[1 : len(value)-1])
if body == "" {
return []string{}, nil
}
reader := csv.NewReader(strings.NewReader(body))
reader.TrimLeadingSpace = true
fields, err := reader.Read()
if err != nil {
return nil, err
}
out := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field != "" {
out = append(out, field)
}
}
return out, nil
}

View file

@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDefaultsMatchPython(t *testing.T) {
cfg := Default()
if cfg.SampleInterval != 4*time.Second || cfg.HeartbeatMaxAge != 120 {
t.Fatalf("unexpected defaults: %+v", cfg)
}
if !cfg.Enabled("deleted_exe") {
t.Fatal("deleted_exe must be enabled by default")
}
}
func TestLoadFlatTOMLAndMultilineDetectorArray(t *testing.T) {
path := filepath.Join(t.TempDir(), "sentinel.toml")
raw := []byte(`
sample_interval = 1.5 # seconds
heartbeat_max_age = 45
detectors = [
"deleted_exe",
"egress",
]
unknown_future_key = true
`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.SampleInterval != 1500*time.Millisecond || cfg.HeartbeatMaxAge != 45 {
t.Fatalf("unexpected parsed config: %+v", cfg)
}
if !cfg.Enabled("deleted_exe") || !cfg.Enabled("egress") || cfg.Enabled("reverse_shell") {
t.Fatalf("unexpected detector set: %#v", cfg.Detectors)
}
}
func TestMissingFileKeepsDefaults(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "missing.toml"))
if err != nil {
t.Fatal(err)
}
if !cfg.Enabled("deleted_exe") {
t.Fatal("missing config should retain defaults")
}
}

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package detectors contains pure Go ports of Sentinel's poll detectors.
package detectors
import (
"fmt"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
var suspiciousDeletedPrefixes = []string{"/tmp/", "/dev/shm/", "/var/tmp/", "/run/"}
// DeletedExe ports enodia_sentinel.detectors.deleted_exe exactly: memfd-backed
// executables always alert, while deleted executables alert only from writable
// or runtime paths.
func DeletedExe(state model.State) []model.Alert {
alerts := make([]model.Alert, 0)
for _, process := range state.Processes {
if !isFileless(process.Exe) {
continue
}
alerts = append(alerts, model.Alert{
SID: 100012,
Severity: "CRITICAL",
Signature: "deleted_exe",
Classtype: "fileless-execution",
Key: fmt.Sprintf("del:%d", process.PID),
Detail: fmt.Sprintf(
"pid=%d comm=%s exe=[%s]", process.PID, process.Comm, process.Exe),
PIDs: []int{process.PID},
})
}
return alerts
}
func isFileless(exe string) bool {
if strings.Contains(exe, "memfd:") {
return true
}
if !strings.Contains(exe, "(deleted)") {
return false
}
for _, prefix := range suspiciousDeletedPrefixes {
if strings.HasPrefix(exe, prefix) {
return true
}
}
return false
}

View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestDeletedExeMatchesPythonCases(t *testing.T) {
state := model.State{Processes: []model.Process{
{PID: 300, Comm: "tmp", Exe: "/tmp/x (deleted)"},
{PID: 301, Comm: "memfd", Exe: "/memfd:foo (deleted)"},
{PID: 302, Comm: "upgrade", Exe: "/usr/bin/x (deleted)"},
{PID: 303, Comm: "normal", Exe: "/usr/bin/x"},
}}
alerts := DeletedExe(state)
if len(alerts) != 2 {
t.Fatalf("expected two alerts, got %#v", alerts)
}
if alerts[0].SID != 100012 || alerts[0].Signature != "deleted_exe" || alerts[0].PIDs[0] != 300 {
t.Fatalf("unexpected alert: %#v", alerts[0])
}
if alerts[1].Key != "del:301" {
t.Fatalf("unexpected memfd alert: %#v", alerts[1])
}
}

View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package model contains implementation-neutral records shared by the Go
// system snapshot, detector, and event layers.
package model
// Process is the subset of /proc process state needed by the first ported
// poll detectors. Fields are added as more Python detectors move across.
type Process struct {
PID int `json:"pid"`
Comm string `json:"comm"`
Cmdline string `json:"cmdline"`
Exe string `json:"exe"`
}
// State is one injectable detector sweep, equivalent to the Python
// SystemState boundary.
type State struct {
Processes []Process `json:"processes"`
}
// Alert matches enodia.alert.v1.
type Alert struct {
SID int `json:"sid"`
Severity string `json:"severity"`
Signature string `json:"signature"`
Classtype string `json:"classtype"`
Key string `json:"key"`
Detail string `json:"detail"`
PIDs []int `json:"pids"`
}

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package schema implements the Go side of Sentinel's stable JSON contracts.
package schema
import (
"fmt"
"os"
"time"
)
const (
EventV1 = "enodia.event.v1"
AlertV1 = "enodia.alert.v1"
StatusV1 = "enodia.status.v1"
)
var eventTypes = map[string]bool{
"alert": true, "incident": true, "status": true,
}
// Status matches the required enodia.status.v1 fields. The prototype sidecar
// has no retained snapshots yet, so those counts remain empty/zero.
type Status struct {
Schema string `json:"schema"`
Version string `json:"version"`
Running bool `json:"running"`
TotalAlerts int `json:"total_alerts"`
Counts map[string]int `json:"counts"`
LastAlert *string `json:"last_alert"`
EBPF string `json:"ebpf"`
EBPFExec string `json:"ebpf_exec"`
EBPFSyscall string `json:"ebpf_syscall"`
Host string `json:"host"`
HeartbeatAge *float64 `json:"heartbeat_age"`
HeartbeatStale bool `json:"heartbeat_stale"`
}
// Build returns the EVE-style enodia.event.v1 envelope used by Python.
func Build(eventType string, payload any, host, timestamp string) (map[string]any, error) {
if !eventTypes[eventType] {
return nil, fmt.Errorf("unknown event_type: %q", eventType)
}
if host == "" {
var err error
host, err = os.Hostname()
if err != nil {
return nil, err
}
}
if timestamp == "" {
timestamp = time.Now().Local().Format(time.RFC3339Nano)
}
return map[string]any{
"schema": EventV1,
"event_type": eventType,
"timestamp": timestamp,
"host": host,
eventType: payload,
}, nil
}

View file

@ -0,0 +1,38 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package schema
import (
"testing"
"time"
)
func TestBuildMatchesEventV1Shape(t *testing.T) {
payload := map[string]any{"sid": 100010}
event, err := Build("alert", payload, "host-a", "2026-07-10T00:00:00-07:00")
if err != nil {
t.Fatal(err)
}
if event["schema"] != EventV1 || event["event_type"] != "alert" || event["host"] != "host-a" {
t.Fatalf("unexpected envelope: %#v", event)
}
if event["alert"].(map[string]any)["sid"] != 100010 {
t.Fatalf("payload not preserved: %#v", event["alert"])
}
}
func TestBuildDefaultsParseAndUnknownTypeFails(t *testing.T) {
event, err := Build("status", map[string]any{"running": true}, "", "")
if err != nil {
t.Fatal(err)
}
if _, err := time.Parse(time.RFC3339Nano, event["timestamp"].(string)); err != nil {
t.Fatalf("timestamp is not RFC3339: %v", err)
}
if event["host"] == "" {
t.Fatal("default host must be populated")
}
if _, err := Build("nope", nil, "", ""); err == nil {
t.Fatal("unknown event type must fail")
}
}

View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package system captures one cached, injectable view of live host state.
package system
import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// Capture reads process state once from procRoot. Transient per-process read
// failures degrade to empty fields, matching Python's fail-open Process view.
func Capture(procRoot string) (model.State, error) {
if procRoot == "" {
procRoot = "/proc"
}
entries, err := os.ReadDir(procRoot)
if err != nil {
return model.State{}, err
}
processes := make([]model.Process, 0, len(entries))
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil || !entry.IsDir() {
continue
}
base := filepath.Join(procRoot, entry.Name())
processes = append(processes, model.Process{
PID: pid,
Comm: strings.TrimSpace(readText(filepath.Join(base, "comm"))),
Cmdline: strings.TrimSpace(strings.ReplaceAll(readText(filepath.Join(base, "cmdline")), "\x00", " ")),
Exe: readLink(filepath.Join(base, "exe")),
})
}
sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID })
return model.State{Processes: processes}, nil
}
func readText(path string) string {
raw, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(raw)
}
func readLink(path string) string {
target, err := os.Readlink(path)
if err != nil {
return ""
}
return target
}

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package system
import (
"os"
"path/filepath"
"testing"
)
func TestCaptureUsesInjectableProcRoot(t *testing.T) {
root := t.TempDir()
proc := filepath.Join(root, "42")
if err := os.Mkdir(proc, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "comm"), []byte("bash\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("bash\x00-i\x00"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/tmp/x (deleted)", filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
state, err := Capture(root)
if err != nil {
t.Fatal(err)
}
if len(state.Processes) != 1 {
t.Fatalf("unexpected processes: %#v", state.Processes)
}
got := state.Processes[0]
if got.PID != 42 || got.Comm != "bash" || got.Cmdline != "bash -i" || got.Exe != "/tmp/x (deleted)" {
t.Fatalf("unexpected process: %#v", got)
}
}