Skip to content

Commit ad9c699

Browse files
System Administratorclaude
andcommitted
Add eBPF collector, PID index GC/wraparound resilience, fleet server, and normalizer improvements
- eBPF process collector with automatic fallback to psutil snapshot-only mode - PID index garbage collection: remove_nodes() synced from reaper and allowlist cleanup - PID wraparound fix: epoch-based ordering in get_latest_node_id/get_node_ids - Self-healing stale pointer eviction in graph query hot paths - Fleet gRPC server with auth, NTP sync, and Neo4j backend - File attribution enrichment and normalizer file activity handling - Systemd service unit and docker-compose updates - Dashboard events view and server updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16a0848 commit ad9c699

43 files changed

Lines changed: 3814 additions & 270 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agent/collectors/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,27 @@
1515

1616
def get_collectors(db_path: str | None = None) -> list[Collector]:
1717
"""Return collectors appropriate for the current platform."""
18-
collectors: list[Collector] = [PsutilCollector()]
18+
collectors: list[Collector] = []
1919
system = platform.system()
2020

2121
if system == "Linux":
22+
ebpf_available = False
23+
try:
24+
from .ebpf_collector import EbpfCollector
25+
26+
_probe = EbpfCollector()
27+
_probe.start() # verify BPF loads
28+
_probe.stop()
29+
collectors.append(EbpfCollector()) # fresh instance
30+
ebpf_available = True
31+
logger.info("eBPF collector active, psutil in snapshot-only mode")
32+
except Exception:
33+
logger.info(
34+
"eBPF collector not available (requires root + BCC + kernel headers), falling back to psutil polling"
35+
)
36+
37+
collectors.append(PsutilCollector(snapshot_only=ebpf_available))
38+
2239
from .linux import LinuxCollector
2340

2441
collectors.append(LinuxCollector())
@@ -29,6 +46,7 @@ def get_collectors(db_path: str | None = None) -> list[Collector]:
2946
except Exception:
3047
logger.debug("Auditd collector not available", exc_info=True)
3148
elif system == "Darwin":
49+
collectors.append(PsutilCollector())
3250
from .macos import MacOSCollector
3351

3452
collectors.append(MacOSCollector())
@@ -63,6 +81,7 @@ def get_collectors(db_path: str | None = None) -> list[Collector]:
6381
except Exception:
6482
logger.debug("Connection metadata collector not available", exc_info=True)
6583
elif system == "Windows":
84+
collectors.append(PsutilCollector())
6685
from .windows import WindowsCollector
6786

6887
collectors.append(WindowsCollector())
@@ -73,6 +92,7 @@ def get_collectors(db_path: str | None = None) -> list[Collector]:
7392
except Exception:
7493
logger.debug("ETW collector not available", exc_info=True)
7594
else:
95+
collectors.append(PsutilCollector())
7696
logger.warning("Unknown platform %s, using psutil only", system)
7797

7898
return collectors

agent/collectors/ebpf_collector.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""Linux eBPF process collector via BCC.
2+
3+
Hooks ``tracepoint:syscalls:sys_enter_execve`` to capture every ``execve``
4+
synchronously from the kernel, with audit UID (AUID) and cgroup v2 ID for
5+
sudo-transparent attribution and container awareness.
6+
7+
Requires root and the BCC Python library (``python3-bcc`` on Fedora).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import collections
13+
import logging
14+
import os
15+
import pwd
16+
import socket
17+
import struct
18+
import threading
19+
from datetime import datetime
20+
21+
from .base import Collector, RawEvent
22+
23+
logger = logging.getLogger(__name__)
24+
25+
_BUFFER_MAX = 10_000
26+
27+
_BPF_PROGRAM = r"""
28+
#include <uapi/linux/ptrace.h>
29+
#include <linux/sched.h>
30+
#include <net/sock.h>
31+
32+
struct exec_event_t {
33+
u32 pid;
34+
u32 ppid;
35+
u32 uid;
36+
u32 gid;
37+
u64 cgroup_id;
38+
char comm[16];
39+
char filename[256];
40+
};
41+
42+
BPF_PERF_OUTPUT(exec_events);
43+
44+
TRACEPOINT_PROBE(syscalls, sys_enter_execve) {
45+
struct exec_event_t event = {};
46+
u64 pid_tgid = bpf_get_current_pid_tgid();
47+
u64 uid_gid = bpf_get_current_uid_gid();
48+
49+
event.pid = pid_tgid >> 32;
50+
event.uid = uid_gid & 0xFFFFFFFF;
51+
event.gid = uid_gid >> 32;
52+
event.cgroup_id = bpf_get_current_cgroup_id();
53+
54+
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
55+
event.ppid = task->real_parent->tgid;
56+
57+
bpf_get_current_comm(&event.comm, sizeof(event.comm));
58+
bpf_probe_read_user_str(event.filename, sizeof(event.filename), args->filename);
59+
60+
exec_events.perf_submit(args, &event, sizeof(event));
61+
return 0;
62+
}
63+
64+
// ── tcp_v4_connect kprobe / kretprobe ───────────────────────────
65+
struct ipv4_event_t {
66+
u32 pid;
67+
u32 uid;
68+
u32 saddr;
69+
u32 daddr;
70+
u16 dport;
71+
char comm[16];
72+
};
73+
74+
BPF_HASH(currsock, u32, struct sock *);
75+
BPF_PERF_OUTPUT(network_events);
76+
77+
int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk) {
78+
u32 pid = bpf_get_current_pid_tgid() >> 32;
79+
currsock.update(&pid, &sk);
80+
return 0;
81+
}
82+
83+
int kretprobe__tcp_v4_connect(struct pt_regs *ctx) {
84+
int ret = PT_REGS_RC(ctx);
85+
u32 pid = bpf_get_current_pid_tgid() >> 32;
86+
87+
struct sock **skpp = currsock.lookup(&pid);
88+
if (skpp == 0) {
89+
return 0;
90+
}
91+
92+
// Always clean up the hash entry
93+
struct sock *skp = *skpp;
94+
currsock.delete(&pid);
95+
96+
if (ret != 0) {
97+
// Connection failed — skip
98+
return 0;
99+
}
100+
101+
struct ipv4_event_t event = {};
102+
event.pid = pid;
103+
event.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
104+
event.saddr = skp->__sk_common.skc_rcv_saddr;
105+
event.daddr = skp->__sk_common.skc_daddr;
106+
event.dport = skp->__sk_common.skc_dport;
107+
bpf_get_current_comm(&event.comm, sizeof(event.comm));
108+
109+
network_events.perf_submit(ctx, &event, sizeof(event));
110+
return 0;
111+
}
112+
"""
113+
114+
# Sentinel value for unset AUID in /proc/<pid>/loginuid
115+
_AUID_UNSET = 4294967295
116+
117+
118+
class EbpfCollector(Collector):
119+
"""Real-time Linux process event collector via eBPF.
120+
121+
Architecture mirrors :class:`AuditdCollector`: bounded deque + lock +
122+
daemon thread + start/stop lifecycle.
123+
"""
124+
125+
def __init__(self) -> None:
126+
self._hostname = socket.gethostname()
127+
self._buffer: collections.deque[RawEvent] = collections.deque(maxlen=_BUFFER_MAX)
128+
self._lock = threading.Lock()
129+
self._thread: threading.Thread | None = None
130+
self._stop_event = threading.Event()
131+
self._bpf = None
132+
self._agent_pid = os.getpid()
133+
134+
def name(self) -> str:
135+
return "ebpf"
136+
137+
def start(self) -> None:
138+
"""Load BPF program and spawn perf-buffer consumer thread."""
139+
if self._thread is not None:
140+
return
141+
from bcc import BPF
142+
143+
self._bpf = BPF(text=_BPF_PROGRAM)
144+
self._bpf["exec_events"].open_perf_buffer(self._process_exec_event)
145+
self._bpf["network_events"].open_perf_buffer(self._process_network_event)
146+
self._stop_event.clear()
147+
self._thread = threading.Thread(
148+
target=self._consume,
149+
daemon=True,
150+
name="ebpf-consumer",
151+
)
152+
self._thread.start()
153+
154+
def stop(self) -> None:
155+
"""Signal the consumer to stop and clean up BPF resources."""
156+
self._stop_event.set()
157+
if self._bpf is not None:
158+
self._bpf.cleanup()
159+
self._bpf = None
160+
self._thread = None
161+
162+
def collect(self) -> list[RawEvent]:
163+
"""Drain buffered eBPF events."""
164+
events: list[RawEvent] = []
165+
with self._lock:
166+
while self._buffer:
167+
events.append(self._buffer.popleft())
168+
return events
169+
170+
def _consume(self) -> None:
171+
"""Poll perf buffer in a loop until stopped."""
172+
try:
173+
while not self._stop_event.is_set():
174+
if self._bpf is not None:
175+
self._bpf.perf_buffer_poll(timeout=1000)
176+
except Exception:
177+
logger.debug("eBPF consumer error", exc_info=True)
178+
179+
def _process_exec_event(self, cpu, data, size) -> None:
180+
"""Perf buffer callback — transform BPF struct into RawEvent."""
181+
event = self._bpf["exec_events"].event(data)
182+
183+
pid = event.pid
184+
if pid == self._agent_pid:
185+
return
186+
187+
ppid = event.ppid
188+
uid = event.uid
189+
cgroup_id = event.cgroup_id
190+
comm = event.comm.decode("utf-8", errors="replace")
191+
filename = event.filename.decode("utf-8", errors="replace")
192+
now = datetime.now()
193+
194+
auid_raw = _read_loginuid(pid)
195+
username = _resolve_username(auid_raw, uid)
196+
197+
fields = {
198+
"pid": str(pid),
199+
"name": comm,
200+
"username": username,
201+
"cmdline": filename,
202+
"exe": filename,
203+
"ppid": str(ppid),
204+
"create_time": now.isoformat(),
205+
"auid": str(auid_raw) if auid_raw is not None else "",
206+
"cgroup_id": str(cgroup_id),
207+
"uid": str(uid),
208+
}
209+
210+
raw = RawEvent(
211+
timestamp=now,
212+
source="ebpf_execve",
213+
message=f"execve: {comm} (PID {pid})",
214+
fields=fields,
215+
hostname=self._hostname,
216+
)
217+
with self._lock:
218+
self._buffer.append(raw)
219+
220+
def _process_network_event(self, cpu, data, size) -> None:
221+
"""Perf buffer callback — transform BPF ipv4_event_t into RawEvent."""
222+
event = self._bpf["network_events"].event(data)
223+
224+
pid = event.pid
225+
if pid == self._agent_pid:
226+
return
227+
228+
uid = event.uid
229+
comm = event.comm.decode("utf-8", errors="replace")
230+
dst_ip = socket.inet_ntop(socket.AF_INET, struct.pack("I", event.daddr))
231+
src_ip = socket.inet_ntop(socket.AF_INET, struct.pack("I", event.saddr))
232+
dst_port = socket.ntohs(event.dport)
233+
now = datetime.now()
234+
235+
auid_raw = _read_loginuid(pid)
236+
username = _resolve_username(auid_raw, uid)
237+
238+
fields = {
239+
"pid": str(pid),
240+
"process_name": comm,
241+
"src_ip": src_ip,
242+
"src_port": "0",
243+
"dst_ip": dst_ip,
244+
"dst_port": str(dst_port),
245+
"status": "ESTABLISHED",
246+
"type": "TCP",
247+
"uid": str(uid),
248+
"username": username,
249+
}
250+
251+
raw = RawEvent(
252+
timestamp=now,
253+
source="ebpf_network",
254+
message=f"connect: {comm} -> {dst_ip}:{dst_port}",
255+
fields=fields,
256+
hostname=self._hostname,
257+
)
258+
with self._lock:
259+
self._buffer.append(raw)
260+
261+
262+
def _read_loginuid(pid: int) -> int | None:
263+
"""Read the audit login UID from ``/proc/<pid>/loginuid``.
264+
265+
Returns the integer AUID, or ``None`` if the file cannot be read
266+
(process already exited, permission denied, etc.).
267+
"""
268+
try:
269+
with open(f"/proc/{pid}/loginuid") as f:
270+
return int(f.read().strip())
271+
except (OSError, ValueError):
272+
return None
273+
274+
275+
def _resolve_username(auid_raw: int | None, effective_uid: int) -> str:
276+
"""Resolve a human-readable username from AUID, falling back to EUID.
277+
278+
AUID (audit UID) persists through ``sudo`` and ``su``, so a process
279+
running as root via sudo will be attributed to the original login user.
280+
"""
281+
# Prefer AUID if set and valid
282+
if auid_raw is not None and auid_raw != _AUID_UNSET:
283+
try:
284+
return pwd.getpwuid(auid_raw).pw_name
285+
except KeyError:
286+
pass
287+
288+
# Fall back to effective UID
289+
try:
290+
return pwd.getpwuid(effective_uid).pw_name
291+
except KeyError:
292+
return str(effective_uid)

agent/collectors/psutil_collector.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,28 @@ class PsutilCollector(Collector):
2121
Diffs against previous snapshots to detect new processes and connections.
2222
"""
2323

24-
def __init__(self) -> None:
24+
def __init__(self, snapshot_only: bool = False) -> None:
2525
self._hostname = socket.gethostname()
2626
self._prev_pids: set[int] = set()
2727
self._prev_conns: set[tuple] = set()
2828
self._initialized = False
2929
self._agent_pid = os.getpid()
3030
self._agent_pids: set[int] = set() # refreshed each cycle
31+
self._snapshot_only = snapshot_only
32+
self._has_run = False
3133

3234
def name(self) -> str:
3335
return "psutil"
3436

3537
def collect(self) -> list[RawEvent]:
38+
if self._snapshot_only and self._has_run:
39+
return []
3640
self._refresh_agent_pids()
3741
events: list[RawEvent] = []
3842
events.extend(self._collect_processes())
3943
events.extend(self._collect_network())
44+
if self._snapshot_only:
45+
self._has_run = True
4046
return events
4147

4248
def _refresh_agent_pids(self) -> None:
@@ -61,7 +67,9 @@ def _collect_processes(self) -> list[RawEvent]:
6167
pid = info["pid"]
6268
current_pids.add(pid)
6369

64-
if self._initialized and pid not in self._prev_pids and pid not in self._agent_pids:
70+
if pid not in self._agent_pids and (
71+
self._snapshot_only or (self._initialized and pid not in self._prev_pids)
72+
):
6573
cmdline = " ".join(info["cmdline"]) if info["cmdline"] else ""
6674
create_time = datetime.fromtimestamp(info["create_time"]) if info["create_time"] else now
6775
events.append(
@@ -115,7 +123,9 @@ def _collect_network(self) -> list[RawEvent]:
115123
)
116124
current_conns.add(conn_key)
117125

118-
if self._initialized and conn_key not in self._prev_conns and (conn.pid or 0) not in self._agent_pids:
126+
if (conn.pid or 0) not in self._agent_pids and (
127+
self._snapshot_only or (self._initialized and conn_key not in self._prev_conns)
128+
):
119129
proc_name = ""
120130
if conn.pid:
121131
with contextlib.suppress(psutil.NoSuchProcess, psutil.AccessDenied):

0 commit comments

Comments
 (0)