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

View file

@ -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 <incident-id> --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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -29,8 +29,11 @@ Required fields:
| `host` | string | Hostname at emission time. |
| `<event_type>` | 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`

View file

@ -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`,

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)
}
}

72
scripts/check-go-parity.py Executable file
View file

@ -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())

8
tests/fixtures/go/deleted-exe.json vendored Normal file
View file

@ -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"}
]
}