From f02509aab5ae99419aaab76bbc4d21a3c00a04e0 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 10 Jul 2026 05:02:50 -0700 Subject: [PATCH] feat(go): add Phase 1 parity sidecar --- CLAUDE.md | 12 ++ Makefile | 8 +- README.md | 16 ++ docs/ROADMAP.md | 12 +- docs/SCHEMAS.md | 5 +- docs/SURICATA_ASSIMILATION.md | 5 +- go-agent/README.md | 35 ++++ go-agent/cmd/enodia-sentinel-go/main.go | 75 ++++++++ go-agent/go.mod | 3 + go-agent/internal/agent/agent.go | 119 ++++++++++++ go-agent/internal/agent/agent_test.go | 54 ++++++ go-agent/internal/config/config.go | 169 ++++++++++++++++++ go-agent/internal/config/config_test.go | 56 ++++++ go-agent/internal/detectors/deleted_exe.go | 51 ++++++ .../internal/detectors/deleted_exe_test.go | 28 +++ go-agent/internal/model/model.go | 31 ++++ go-agent/internal/schema/event.go | 61 +++++++ go-agent/internal/schema/event_test.go | 38 ++++ go-agent/internal/system/state.go | 58 ++++++ go-agent/internal/system/state_test.go | 37 ++++ scripts/check-go-parity.py | 72 ++++++++ tests/fixtures/go/deleted-exe.json | 8 + 22 files changed, 947 insertions(+), 6 deletions(-) create mode 100644 go-agent/README.md create mode 100644 go-agent/cmd/enodia-sentinel-go/main.go create mode 100644 go-agent/go.mod create mode 100644 go-agent/internal/agent/agent.go create mode 100644 go-agent/internal/agent/agent_test.go create mode 100644 go-agent/internal/config/config.go create mode 100644 go-agent/internal/config/config_test.go create mode 100644 go-agent/internal/detectors/deleted_exe.go create mode 100644 go-agent/internal/detectors/deleted_exe_test.go create mode 100644 go-agent/internal/model/model.go create mode 100644 go-agent/internal/schema/event.go create mode 100644 go-agent/internal/schema/event_test.go create mode 100644 go-agent/internal/system/state.go create mode 100644 go-agent/internal/system/state_test.go create mode 100755 scripts/check-go-parity.py create mode 100644 tests/fixtures/go/deleted-exe.json diff --git a/CLAUDE.md b/CLAUDE.md index 13b0b35..11223ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,11 @@ HTTPS management console, incident model, response planner, rootcheck, posture checks, file/package integrity checks, push notification backends, and optional bcc/eBPF telemetry. +An experimental, non-installing Phase 1 Go sidecar now lives under `go-agent/`. +It is stdlib-only, emits `enodia.event.v1` JSONL, captures an injectable process +view, and ports only `deleted_exe` so far. Python remains the production agent +and parity oracle; do not describe the Go path as a replacement yet. + Implemented detection coverage: - Poll detectors: `reverse_shell`, `ld_preload`, `deleted_exe`, @@ -149,6 +154,9 @@ Start with: - `tray/` is the optional desktop tray frontend over the CLI and systemctl; only `tray/app.py` may import `pystray`/`PIL` (enforced by an import-guard test). +- `go-agent/` is the isolated Phase 1 sidecar with Go config, process-state, + detector, schema, and sweep-loop packages. It writes JSONL to stdout and + must not share or mutate Python daemon state during parity development. ## Current Threat Mapping @@ -176,6 +184,8 @@ python3 -m enodia_sentinel.cli incident list python3 -m enodia_sentinel.cli respond plan --json python3 -m enodia_sentinel.cli rules list python3 -m enodia_sentinel.cli web +make test-go +make parity-go ``` The web dashboard is HTTPS-only. In development it auto-generates a self-signed @@ -190,6 +200,8 @@ certificate. `/sys`, or `ss`. - Web tests bind a local HTTPS socket and may require permission in sandboxed environments. +- Go tests need a writable build cache; repo targets use + `GOCACHE=/tmp/enodia-go-cache` for sandbox-safe verification. - Run the full suite before committing: ```bash diff --git a/Makefile b/Makefile index bb0a979..8aae959 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ LOGDIR := /var/log/enodia-sentinel DOCDIR := $(PREFIX)/share/doc/enodia-sentinel TMPFILESDIR := /etc/tmpfiles.d -.PHONY: install uninstall enable disable status logs check baseline drill test release-artifacts clean +.PHONY: install uninstall enable disable status logs check baseline drill test test-go parity-go release-artifacts clean # Installs the stdlib-only Python package as a plain directory + launcher # wrapper (no pip, no virtualenv, no site-packages). Zero runtime deps. @@ -72,6 +72,12 @@ logs: test: python3 -m unittest discover -s tests -v +test-go: + cd go-agent && GOCACHE=/tmp/enodia-go-cache go test ./... + +parity-go: + python3 scripts/check-go-parity.py + check: python3 -m enodia_sentinel.cli check diff --git a/README.md b/README.md index 1fc47a7..1fcbb8d 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ Project docs: > detectors, and JSON output. The bash version and the Python version share one > red-team harness, so every signature is exercised against both. +Phase 1 now also has an **experimental Go sidecar** under `go-agent/`. It emits +the same `enodia.event.v1` envelope, captures an injectable `/proc` process +view, and ports `deleted_exe` as the first parity-checked poll detector. It is +stdout-only and does not replace, install over, or write state for the Python +daemon; Python remains authoritative until subsystem parity is complete. + ## Why these detectors Every detector keys on a behavior that is **cheap to observe** and **expensive @@ -162,6 +168,14 @@ enodia_sentinel/ └── monitor.py runs the probe on a thread, routes events → rules ``` +The parallel Phase 1 tree is intentionally isolated: + +```text +go-agent/ +├── cmd/enodia-sentinel-go/ experimental JSONL sidecar +└── internal/ config · model · system · detectors · schema · loop +``` + Three complementary detection paths feed one Alert → snapshot pipeline: - **Poll** — every few seconds, sweep `/proc`/`ss` (catches anything lingering). @@ -290,6 +304,8 @@ drill only sets the env var on a throwaway process. ```bash make test # stdlib unittest suite, no runtime deps +make test-go # stdlib-only Go sidecar unit tests +make parity-go # shared fixture: Go output vs Python oracle ``` Detectors are pure functions over an injectable `SystemState`, so tests build diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 054bfd4..5ef833e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -255,6 +255,15 @@ Exit criteria: - A local root attacker has to tamper with both the host and an external or hardware-backed trust anchor to hide compromise. +## Active Migration Track + +- Suricata model assimilation design is complete, and Phase 0's additive + `enodia.event.v1` Python reference contract is implemented. +- Phase 1 is in progress under `go-agent/`: a stdlib-only parallel sweep loop, + injectable process state, and the first poll-detector port (`deleted_exe`) + are checked against the Python oracle with a shared fixture. The Go sidecar + remains stdout-only and non-production until broader detector parity lands. + ## Backlog Useful ideas that need design before implementation: @@ -263,9 +272,6 @@ Useful ideas that need design before implementation: - YARA-compatible scanning for selected paths. - Sigma-like host event rules. - SBOM and package provenance reporting. -- Suricata model assimilation: stateful sessions, EVE-style event envelope, - rule metadata/thresholding, datasets, and triggered forensic capture - ([SURICATA_ASSIMILATION.md](SURICATA_ASSIMILATION.md)). - Offline evidence bundle for outside analysis. - Dashboard import of exported incident bundles. - Support for non-pacman package managers in signed-package verification. diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index af943b1..075afb3 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -29,8 +29,11 @@ Required fields: | `host` | string | Hostname at emission time. | | `` | object | Payload, under a key equal to `event_type` (e.g. `alert` holds an `enodia.alert.v1` object). | -The reference encoder lives in `enodia_sentinel/event.py` +The Python reference encoder lives in `enodia_sentinel/event.py` (`build_event`, `from_alert`); `tests/test_event_envelope.py` pins the contract. +The experimental Go encoder lives in `go-agent/internal/schema`; the shared +fixture harness `scripts/check-go-parity.py` compares its alert envelopes with +the Python oracle. ## `enodia.alert.v1` diff --git a/docs/SURICATA_ASSIMILATION.md b/docs/SURICATA_ASSIMILATION.md index 2e9cb70..dc228ae 100644 --- a/docs/SURICATA_ASSIMILATION.md +++ b/docs/SURICATA_ASSIMILATION.md @@ -226,7 +226,10 @@ signature keeps `sid` / `signature` / `classtype` / tests / docs. as event types). This is the Python↔Go contract. *No language change yet.* - **Phase 1 — Go agent skeleton.** Config, daemon sweep loop, `SystemState` equivalent, poll detectors ported, JSON output matching schemas. Runs - alongside Python; harness compares both. + alongside Python; harness compares both. *In progress:* the isolated + `go-agent/` sidecar now has stdlib config loading, an injectable `/proc` + process view, the JSONL sweep loop, and fixture parity for `deleted_exe`; + Python remains the production agent. - **Phase 2 — eBPF via `cilium/ebpf`.** Port exec/syscall rules; drop the bcc/Python runtime dependency; single static binary. - **Phase 3 — Rule-engine parity + assimilation.** `rev`/`reference`/`metadata`, diff --git a/go-agent/README.md b/go-agent/README.md new file mode 100644 index 0000000..739de8f --- /dev/null +++ b/go-agent/README.md @@ -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. diff --git a/go-agent/cmd/enodia-sentinel-go/main.go b/go-agent/cmd/enodia-sentinel-go/main.go new file mode 100644 index 0000000..956a968 --- /dev/null +++ b/go-agent/cmd/enodia-sentinel-go/main.go @@ -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) + }) +} diff --git a/go-agent/go.mod b/go-agent/go.mod new file mode 100644 index 0000000..41f4180 --- /dev/null +++ b/go-agent/go.mod @@ -0,0 +1,3 @@ +module codeberg.org/anassaeneroi/enodia-sentinal/go-agent + +go 1.23 diff --git a/go-agent/internal/agent/agent.go b/go-agent/internal/agent/agent.go new file mode 100644 index 0000000..c877a3a --- /dev/null +++ b/go-agent/internal/agent/agent.go @@ -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: + } + } +} diff --git a/go-agent/internal/agent/agent_test.go b/go-agent/internal/agent/agent_test.go new file mode 100644 index 0000000..2daf6c6 --- /dev/null +++ b/go-agent/internal/agent/agent_test.go @@ -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) + } +} diff --git a/go-agent/internal/config/config.go b/go-agent/internal/config/config.go new file mode 100644 index 0000000..280c08e --- /dev/null +++ b/go-agent/internal/config/config.go @@ -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 +} diff --git a/go-agent/internal/config/config_test.go b/go-agent/internal/config/config_test.go new file mode 100644 index 0000000..de02fff --- /dev/null +++ b/go-agent/internal/config/config_test.go @@ -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") + } +} diff --git a/go-agent/internal/detectors/deleted_exe.go b/go-agent/internal/detectors/deleted_exe.go new file mode 100644 index 0000000..a2dab7d --- /dev/null +++ b/go-agent/internal/detectors/deleted_exe.go @@ -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 +} diff --git a/go-agent/internal/detectors/deleted_exe_test.go b/go-agent/internal/detectors/deleted_exe_test.go new file mode 100644 index 0000000..3cd5054 --- /dev/null +++ b/go-agent/internal/detectors/deleted_exe_test.go @@ -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]) + } +} diff --git a/go-agent/internal/model/model.go b/go-agent/internal/model/model.go new file mode 100644 index 0000000..57827d9 --- /dev/null +++ b/go-agent/internal/model/model.go @@ -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"` +} diff --git a/go-agent/internal/schema/event.go b/go-agent/internal/schema/event.go new file mode 100644 index 0000000..c528217 --- /dev/null +++ b/go-agent/internal/schema/event.go @@ -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 +} diff --git a/go-agent/internal/schema/event_test.go b/go-agent/internal/schema/event_test.go new file mode 100644 index 0000000..cc685d2 --- /dev/null +++ b/go-agent/internal/schema/event_test.go @@ -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") + } +} diff --git a/go-agent/internal/system/state.go b/go-agent/internal/system/state.go new file mode 100644 index 0000000..e42b080 --- /dev/null +++ b/go-agent/internal/system/state.go @@ -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 +} diff --git a/go-agent/internal/system/state_test.go b/go-agent/internal/system/state_test.go new file mode 100644 index 0000000..036009a --- /dev/null +++ b/go-agent/internal/system/state_test.go @@ -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) + } +} diff --git a/scripts/check-go-parity.py b/scripts/check-go-parity.py new file mode 100755 index 0000000..c0bfdda --- /dev/null +++ b/scripts/check-go-parity.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Compare the first Go detector port with the Python reference oracle.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from enodia_sentinel import __version__, event # noqa: E402 +from enodia_sentinel.config import Config # noqa: E402 +from enodia_sentinel.detectors import deleted_exe # noqa: E402 +from enodia_sentinel.system import SystemState # noqa: E402 + +HOST = "parity-host" +TIMESTAMP = "2026-07-10T00:00:00-07:00" + + +def main() -> int: + fixture = ROOT / "tests/fixtures/go/deleted-exe.json" + fixture_data = json.loads(fixture.read_text()) + env = os.environ.copy() + env.pop("ENODIA_CONFIG", None) + env["GOCACHE"] = "/tmp/enodia-go-cache" + command = [ + "go", "run", "./cmd/enodia-sentinel-go", + "--once", "--fixture", str(fixture), + "--host", HOST, "--timestamp", TIMESTAMP, + ] + result = subprocess.run( + command, cwd=ROOT / "go-agent", env=env, + capture_output=True, text=True, timeout=60, + ) + if result.returncode: + print(result.stderr, file=sys.stderr, end="") + return result.returncode + go_events = [json.loads(line) for line in result.stdout.splitlines() if line] + go_alerts = [record for record in go_events if record["event_type"] == "alert"] + + processes = [SimpleNamespace(**item) for item in fixture_data["processes"]] + cfg = Config() + cfg.detectors = frozenset({"deleted_exe"}) + state = SystemState(processes=processes, sockets=[]) + python_alerts = [ + event.from_alert(alert, host=HOST, timestamp=TIMESTAMP) + for alert in deleted_exe.detect(state, cfg) + ] + if go_alerts != python_alerts: + print("Go/Python alert parity mismatch", file=sys.stderr) + print(json.dumps({"go": go_alerts, "python": python_alerts}, indent=2), file=sys.stderr) + return 1 + + status_events = [record for record in go_events if record["event_type"] == "status"] + if len(status_events) != 1 or status_events[0].get("schema") != "enodia.event.v1": + print("Go sidecar did not emit one enodia.event.v1 status record", file=sys.stderr) + return 1 + status = status_events[0].get("status", {}) + if status.get("schema") != "enodia.status.v1" or status.get("version") != __version__: + print("Go status schema/version drifted from Python", file=sys.stderr) + return 1 + print(f"parity ok: {len(go_alerts)} deleted_exe alert events") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/go/deleted-exe.json b/tests/fixtures/go/deleted-exe.json new file mode 100644 index 0000000..35c6dee --- /dev/null +++ b/tests/fixtures/go/deleted-exe.json @@ -0,0 +1,8 @@ +{ + "processes": [ + {"pid": 300, "comm": "tmpdrop", "cmdline": "tmpdrop", "exe": "/tmp/x (deleted)"}, + {"pid": 301, "comm": "memdrop", "cmdline": "memdrop", "exe": "/memfd:foo (deleted)"}, + {"pid": 302, "comm": "upgrade", "cmdline": "upgrade", "exe": "/usr/bin/x (deleted)"}, + {"pid": 303, "comm": "normal", "cmdline": "normal", "exe": "/usr/bin/x"} + ] +}