Building a Small Rust eBPF EDR 1: MVP Architecture
Table of Contents
rand-guard is a small eBPF EDR project built with Rust and Aya. I use the term EDR, but the goal is not to imitate a commercial security product. The purpose of this project is to implement the full flow myself: observe behavior on a Linux endpoint from the kernel, turn it into meaningful events in userspace, and write simple detection results as JSON logs.
I designed this project as a small end-to-end pipeline rather than a broad feature list. It handles a few signals: process execution, file open or modification activity, and network connections. The important part is understanding how those signals move from an eBPF program to the userspace runtime, where policy decisions belong, and what information should be written so an investigator can understand the event.
Why a Small EDR
An EDR is ultimately a system that collects endpoint behavior and decides whether that behavior is worth investigating. Real products are much more complex. They need many event sources, complex correlation, remote management, response actions, tamper resistance, and large-scale storage.
But if a study project starts with that entire scope, it is easy to miss the core. rand-guard focuses on these questions instead.
- Where can process, file, and network behavior be observed on Linux?
- How much data can eBPF read safely?
- How should the work be split between the kernel and userspace?
- How can raw events become JSON records that humans can read and tests can verify?
- Where should detection rules live so they are easy to change?
To answer those questions, the current project has a small but complete pipeline.
Linux tracepoints
-> eBPF programs
-> EVENTS ring buffer
-> userspace runtime
-> normalization and enrichment
-> detections and rules
-> NDJSON outputOverall Structure
The repository is organized around four main pieces.
crates/commondefines the event ABI shared by eBPF and userspace.crates/ebpfcontains the Aya-basedno_std,no_maineBPF programs.crates/userloads the eBPF object, reads the ring buffer, normalizes events, evaluates rules, and writes JSON output.xtaskgroups repeated workflows such as build, test, package, run, smoke, and throughput.
The architecture looks like this.
flowchart TD
A[Linux tracepoints] --> B[crates/ebpf]
B --> C[EVENTS ring buffer]
C --> D[crates/user]
D --> E[Normalize raw events]
E --> F[Process enrichment]
F --> G[Detections and rules]
G --> H[NDJSON stdout]
I[config.toml] --> D
The important point is that detection policy is not pushed deeply into the kernel side. The eBPF programs read syscall arguments and process metadata in a bounded way, build fixed-size event structs, and submit them to the ring buffer. Normalization, filtering, detection, and output happen in userspace.
That boundary is intentional. eBPF has verifier constraints, and it should avoid allocation, complex parsing, long loops, and large stack usage. Userspace is a much better place to read config, handle strings, maintain a process table, and write JSON. Rebuilding the eBPF object every time a rule changes would also be a poor design.
Events Collected Today
The current MVP handles three event families.
The first is process lifecycle telemetry. Tracepoints related to execve, execveat, fork, and exit are used to observe process starts, parent-child relationships, and process exits. Userspace stores this information in a process table so later file and network events can be enriched with context such as ppid, comm, and exe_path.
The second is file telemetry. The project observes open, write, rename, and unlink syscall families. Instead of treating every file equally, it narrows attention with watch_paths, watch_patterns, and exclude_paths. Built-in detections then focus on persistence-related locations such as systemd service paths and cron paths.
The third is network telemetry. Network collection is disabled by default. Both events.network = true and network.enabled = true must be set before connect, bind, and listen syscall tracepoints are attached. DNS, payload collection, accept, and socket lifecycle correlation are not implemented.
Output Model
rand-guard writes one JSON object per line, using NDJSON.
A process event can look like this.
{"event_type":"process_start","pid":100,"comm":"bash","source":"execve"}
A file event with built-in detection metadata can include alert and detection_type on the source event itself.
{"event_type":"file_write","resolved_path":"/etc/systemd/system/demo.service","alert":true,"detection_type":"systemd_service_modified"}
When a generic [[rules]] entry matches, the runtime also emits a separate stable alert record.
{"event_type":"alert","rule_id":"FILE-001","rule_type":"file","source_event_type":"file_write"}
NDJSON is intentionally simple. It is easy to test, easy to inspect with jq or rg, and easy to connect to stdout or journald. At this stage, a stable event shape matters more than a complex storage backend.
First Run Flow
In a development environment, the project can be built and run through xtask.
cargo run -p xtask -- build
cargo run -p xtask -- run
Loading an eBPF program requires root or suitable Linux capabilities. After the agent is running, a simple process event can be generated from another terminal.
/bin/true
With the default config, process and file events are enabled and network events are disabled. That default reduces first-run noise.
Current Limits
This project is not a production-ready EDR. It does not protect the agent from a root attacker, and it does not provide tamper-proof logging. It does not collect network payloads or DNS. The rule engine is a single-event matcher MVP with no regex, expression DSL, time windows, or multi-event correlation.
These limits are also what keep the project scope clear. The current goal is not to attach many shallow features, but to understand a small and correct security telemetry pipeline from eBPF to userspace.
Part 1 Summary
This post introduced the overall structure of rand-guard.
- The kernel side performs small tracepoint-based event collection.
- eBPF and userspace are connected through a fixed ABI and a ring buffer.
- Userspace owns normalization, enrichment, detection, rule evaluation, and NDJSON output.
- The current scope is process, file, opt-in network telemetry, and single-event rule evaluation.
The next post covers the ABI between eBPF and userspace. It explains how raw bytes become stable Rust events and why fixed-layout structs and schema versions matter.