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
12
CLAUDE.md
12
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
|
checks, file/package integrity checks, push notification backends, and optional
|
||||||
bcc/eBPF telemetry.
|
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:
|
Implemented detection coverage:
|
||||||
|
|
||||||
- Poll detectors: `reverse_shell`, `ld_preload`, `deleted_exe`,
|
- 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;
|
- `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
|
only `tray/app.py` may import `pystray`/`PIL` (enforced by an import-guard
|
||||||
test).
|
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
|
## 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 respond plan <incident-id> --json
|
||||||
python3 -m enodia_sentinel.cli rules list
|
python3 -m enodia_sentinel.cli rules list
|
||||||
python3 -m enodia_sentinel.cli web
|
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
|
The web dashboard is HTTPS-only. In development it auto-generates a self-signed
|
||||||
|
|
@ -190,6 +200,8 @@ certificate.
|
||||||
`/sys`, or `ss`.
|
`/sys`, or `ss`.
|
||||||
- Web tests bind a local HTTPS socket and may require permission in sandboxed
|
- Web tests bind a local HTTPS socket and may require permission in sandboxed
|
||||||
environments.
|
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:
|
- Run the full suite before committing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
8
Makefile
8
Makefile
|
|
@ -7,7 +7,7 @@ LOGDIR := /var/log/enodia-sentinel
|
||||||
DOCDIR := $(PREFIX)/share/doc/enodia-sentinel
|
DOCDIR := $(PREFIX)/share/doc/enodia-sentinel
|
||||||
TMPFILESDIR := /etc/tmpfiles.d
|
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
|
# Installs the stdlib-only Python package as a plain directory + launcher
|
||||||
# wrapper (no pip, no virtualenv, no site-packages). Zero runtime deps.
|
# wrapper (no pip, no virtualenv, no site-packages). Zero runtime deps.
|
||||||
|
|
@ -72,6 +72,12 @@ logs:
|
||||||
test:
|
test:
|
||||||
python3 -m unittest discover -s tests -v
|
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:
|
check:
|
||||||
python3 -m enodia_sentinel.cli check
|
python3 -m enodia_sentinel.cli check
|
||||||
|
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -64,6 +64,12 @@ Project docs:
|
||||||
> detectors, and JSON output. The bash version and the Python version share one
|
> detectors, and JSON output. The bash version and the Python version share one
|
||||||
> red-team harness, so every signature is exercised against both.
|
> 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
|
## Why these detectors
|
||||||
|
|
||||||
Every detector keys on a behavior that is **cheap to observe** and **expensive
|
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
|
└── 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:
|
Three complementary detection paths feed one Alert → snapshot pipeline:
|
||||||
|
|
||||||
- **Poll** — every few seconds, sweep `/proc`/`ss` (catches anything lingering).
|
- **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
|
```bash
|
||||||
make test # stdlib unittest suite, no runtime deps
|
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
|
Detectors are pure functions over an injectable `SystemState`, so tests build
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,15 @@ Exit criteria:
|
||||||
- A local root attacker has to tamper with both the host and an external or
|
- A local root attacker has to tamper with both the host and an external or
|
||||||
hardware-backed trust anchor to hide compromise.
|
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
|
## Backlog
|
||||||
|
|
||||||
Useful ideas that need design before implementation:
|
Useful ideas that need design before implementation:
|
||||||
|
|
@ -263,9 +272,6 @@ Useful ideas that need design before implementation:
|
||||||
- YARA-compatible scanning for selected paths.
|
- YARA-compatible scanning for selected paths.
|
||||||
- Sigma-like host event rules.
|
- Sigma-like host event rules.
|
||||||
- SBOM and package provenance reporting.
|
- 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.
|
- Offline evidence bundle for outside analysis.
|
||||||
- Dashboard import of exported incident bundles.
|
- Dashboard import of exported incident bundles.
|
||||||
- Support for non-pacman package managers in signed-package verification.
|
- Support for non-pacman package managers in signed-package verification.
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,11 @@ Required fields:
|
||||||
| `host` | string | Hostname at emission time. |
|
| `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). |
|
| `<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.
|
(`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`
|
## `enodia.alert.v1`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,10 @@ signature keeps `sid` / `signature` / `classtype` / tests / docs.
|
||||||
as event types). This is the Python↔Go contract. *No language change yet.*
|
as event types). This is the Python↔Go contract. *No language change yet.*
|
||||||
- **Phase 1 — Go agent skeleton.** Config, daemon sweep loop, `SystemState`
|
- **Phase 1 — Go agent skeleton.** Config, daemon sweep loop, `SystemState`
|
||||||
equivalent, poll detectors ported, JSON output matching schemas. Runs
|
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
|
- **Phase 2 — eBPF via `cilium/ebpf`.** Port exec/syscall rules; drop the
|
||||||
bcc/Python runtime dependency; single static binary.
|
bcc/Python runtime dependency; single static binary.
|
||||||
- **Phase 3 — Rule-engine parity + assimilation.** `rev`/`reference`/`metadata`,
|
- **Phase 3 — Rule-engine parity + assimilation.** `rev`/`reference`/`metadata`,
|
||||||
|
|
|
||||||
35
go-agent/README.md
Normal file
35
go-agent/README.md
Normal 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.
|
||||||
75
go-agent/cmd/enodia-sentinel-go/main.go
Normal file
75
go-agent/cmd/enodia-sentinel-go/main.go
Normal 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
3
go-agent/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module codeberg.org/anassaeneroi/enodia-sentinal/go-agent
|
||||||
|
|
||||||
|
go 1.23
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
scripts/check-go-parity.py
Executable file
72
scripts/check-go-parity.py
Executable 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
8
tests/fixtures/go/deleted-exe.json
vendored
Normal 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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue