feat(go): add Phase 1 parity sidecar
This commit is contained in:
parent
65f5be6420
commit
f02509aab5
22 changed files with 947 additions and 6 deletions
119
go-agent/internal/agent/agent.go
Normal file
119
go-agent/internal/agent/agent.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
54
go-agent/internal/agent/agent_test.go
Normal file
54
go-agent/internal/agent/agent_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
169
go-agent/internal/config/config.go
Normal file
169
go-agent/internal/config/config.go
Normal 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
|
||||
}
|
||||
56
go-agent/internal/config/config_test.go
Normal file
56
go-agent/internal/config/config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
51
go-agent/internal/detectors/deleted_exe.go
Normal file
51
go-agent/internal/detectors/deleted_exe.go
Normal 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
|
||||
}
|
||||
28
go-agent/internal/detectors/deleted_exe_test.go
Normal file
28
go-agent/internal/detectors/deleted_exe_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
31
go-agent/internal/model/model.go
Normal file
31
go-agent/internal/model/model.go
Normal 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"`
|
||||
}
|
||||
61
go-agent/internal/schema/event.go
Normal file
61
go-agent/internal/schema/event.go
Normal 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
|
||||
}
|
||||
38
go-agent/internal/schema/event_test.go
Normal file
38
go-agent/internal/schema/event_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
58
go-agent/internal/system/state.go
Normal file
58
go-agent/internal/system/state.go
Normal 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
|
||||
}
|
||||
37
go-agent/internal/system/state_test.go
Normal file
37
go-agent/internal/system/state_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue