nativerelay 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- collectors/__init__.py +1 -0
- collectors/linux/__init__.py +3 -0
- collectors/linux/collector.py +319 -0
- nativerelay/__init__.py +4 -0
- nativerelay/build.py +49 -0
- nativerelay/cli.py +62 -0
- nativerelay/collector.py +13 -0
- nativerelay/model.py +113 -0
- nativerelay/stream.py +111 -0
- nativerelay-0.1.0.dist-info/METADATA +6 -0
- nativerelay-0.1.0.dist-info/RECORD +13 -0
- nativerelay-0.1.0.dist-info/WHEEL +4 -0
- nativerelay-0.1.0.dist-info/entry_points.txt +2 -0
collectors/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Native platform collectors."""
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Linux process connector and fanotify collectors (Linux only, stdlib only).
|
|
2
|
+
|
|
3
|
+
Process lifecycle uses NETLINK_CONNECTOR/CN_PROC. File events use a fanotify
|
|
4
|
+
mount mark, filtered to one requested directory in userspace. No polling is
|
|
5
|
+
used; permission and queue errors are surfaced in capabilities/health.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import ctypes
|
|
9
|
+
import errno
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import select
|
|
15
|
+
import socket
|
|
16
|
+
import struct
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from nativerelay.collector import Collector
|
|
22
|
+
from nativerelay.model import Capability, CollectorStatus, Event, EventType, Process, Resource
|
|
23
|
+
from nativerelay.stream import EventStream
|
|
24
|
+
|
|
25
|
+
NETLINK_CONNECTOR = 11
|
|
26
|
+
CN_IDX_PROC = 1
|
|
27
|
+
CN_VAL_PROC = 1
|
|
28
|
+
NLMSG_DONE = 3
|
|
29
|
+
NLMSG_OVERRUN = 4
|
|
30
|
+
PROC_EVENT_FORK = 0x00000001
|
|
31
|
+
PROC_EVENT_EXIT = 0x80000000
|
|
32
|
+
PROC_CN_MCAST_LISTEN = 1
|
|
33
|
+
FAN_CLOEXEC = 0x00000001
|
|
34
|
+
FAN_CLASS_NOTIF = 0x00000000
|
|
35
|
+
FAN_NONBLOCK = 0x00000002
|
|
36
|
+
FAN_MARK_ADD = 0x00000001
|
|
37
|
+
FAN_MARK_MOUNT = 0x00000010
|
|
38
|
+
FAN_MODIFY = 0x00000002
|
|
39
|
+
FAN_OPEN = 0x00000020
|
|
40
|
+
FAN_Q_OVERFLOW = 0x00004000
|
|
41
|
+
FANOTIFY_METADATA_VERSION = 3
|
|
42
|
+
FANOTIFY_METADATA_LEN = 24
|
|
43
|
+
FAN_EVENT_METADATA_FMT = "=IBBHQii"
|
|
44
|
+
|
|
45
|
+
class LinuxCollector(Collector):
|
|
46
|
+
name = "linux-kernel"
|
|
47
|
+
def __init__(self, scope: str, *, include_command: bool = False):
|
|
48
|
+
if platform.system() != "Linux":
|
|
49
|
+
raise OSError("LinuxCollector is available only on Linux")
|
|
50
|
+
self.scope = Path(scope).resolve(strict=True)
|
|
51
|
+
if not self.scope.is_dir(): raise ValueError("scope must be an existing directory")
|
|
52
|
+
self.include_command = include_command
|
|
53
|
+
self._stream = None
|
|
54
|
+
self._sockets = []
|
|
55
|
+
self._threads = []
|
|
56
|
+
self._stop = threading.Event()
|
|
57
|
+
self._publish_lock = threading.Lock()
|
|
58
|
+
self._sequence = 0
|
|
59
|
+
self.health = {"process": "not_started", "filesystem": "not_started", "dropped": 0, "errors": []}
|
|
60
|
+
self._caps = self._initial_capabilities()
|
|
61
|
+
|
|
62
|
+
def _initial_capabilities(self):
|
|
63
|
+
return tuple(Capability(t, CollectorStatus.SUPPORTED if t in (EventType.PROCESS_STARTED, EventType.PROCESS_EXITED, EventType.FILE_OPENED, EventType.FILE_MODIFIED) else CollectorStatus.UNSUPPORTED,
|
|
64
|
+
"collector has not started" if t in (EventType.PROCESS_STARTED, EventType.PROCESS_EXITED, EventType.FILE_OPENED, EventType.FILE_MODIFIED) else
|
|
65
|
+
"This collector does not infer namespace changes from file content notifications",
|
|
66
|
+
"host process table" if t.value.startswith("process") else str(self.scope),
|
|
67
|
+
"kernel PID/TGID" if t.value.startswith("process") else "fanotify event PID") for t in EventType)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def capabilities(self): return self._caps
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def status(self):
|
|
74
|
+
losses = self._stream.losses if self._stream is not None else getattr(self, "_last_stream_losses", {})
|
|
75
|
+
generation = self._stream.loss_generation if self._stream is not None else getattr(self, "_last_loss_generation", 0)
|
|
76
|
+
return {**self.health, "errors": list(self.health["errors"]), "losses": losses, "loss_generation": generation}
|
|
77
|
+
|
|
78
|
+
def start(self, stream: EventStream):
|
|
79
|
+
if self._stream is not None: raise RuntimeError("collector already started")
|
|
80
|
+
self._stop = threading.Event()
|
|
81
|
+
self._publish_lock = threading.Lock()
|
|
82
|
+
self._sequence = 0
|
|
83
|
+
self.health = {"process": "not_started", "filesystem": "not_started", "dropped": 0, "errors": []}
|
|
84
|
+
self._caps = self._initial_capabilities()
|
|
85
|
+
self._stream = stream
|
|
86
|
+
self._last_stream_losses = {}
|
|
87
|
+
self._start_process()
|
|
88
|
+
self._start_filesystem()
|
|
89
|
+
|
|
90
|
+
def stop(self):
|
|
91
|
+
if self._stream is None: return
|
|
92
|
+
self._stop.set()
|
|
93
|
+
for sock in self._sockets:
|
|
94
|
+
try:
|
|
95
|
+
if isinstance(sock, int): os.close(sock)
|
|
96
|
+
else: sock.close()
|
|
97
|
+
except OSError: pass
|
|
98
|
+
for thread in self._threads: thread.join(timeout=2)
|
|
99
|
+
self._last_stream_losses = self._stream.losses
|
|
100
|
+
self._last_loss_generation = self._stream.loss_generation
|
|
101
|
+
self._stream.close()
|
|
102
|
+
self._sockets.clear(); self._threads.clear(); self._stream = None
|
|
103
|
+
|
|
104
|
+
def _failure(self, subsystem, exc):
|
|
105
|
+
status = CollectorStatus.PERMISSION_DENIED if isinstance(exc, PermissionError) or getattr(exc, "errno", None) in (errno.EPERM, errno.EACCES) else CollectorStatus.UNAVAILABLE
|
|
106
|
+
self.health[subsystem] = status.value
|
|
107
|
+
self.health["errors"].append(f"{subsystem}: {exc}")
|
|
108
|
+
affected = (EventType.PROCESS_STARTED, EventType.PROCESS_EXITED) if subsystem == "process" else (EventType.FILE_OPENED, EventType.FILE_MODIFIED)
|
|
109
|
+
self._caps = tuple(Capability(c.event_type, status if c.event_type in affected else c.status,
|
|
110
|
+
str(exc) if c.event_type in affected else c.reason, c.scope, c.attribution) for c in self._caps)
|
|
111
|
+
|
|
112
|
+
def _start_process(self):
|
|
113
|
+
try:
|
|
114
|
+
sock = socket.socket(socket.AF_NETLINK, socket.SOCK_DGRAM, NETLINK_CONNECTOR)
|
|
115
|
+
sock.bind((os.getpid(), CN_IDX_PROC))
|
|
116
|
+
payload = struct.pack("=IIIIHHI", CN_IDX_PROC, CN_VAL_PROC, 0, 0, 4, 0, PROC_CN_MCAST_LISTEN)
|
|
117
|
+
header = struct.pack("=IHHII", 16 + len(payload), NLMSG_DONE, 0, 1, os.getpid())
|
|
118
|
+
sock.send(header + payload)
|
|
119
|
+
self._sockets.append(sock)
|
|
120
|
+
# CN_PROC may silently ignore subscribers outside the initial PID/user namespaces.
|
|
121
|
+
# Require its connector acknowledgement before claiming process coverage.
|
|
122
|
+
deadline = time.monotonic() + 2.0
|
|
123
|
+
while self.health["process"] != "available":
|
|
124
|
+
remaining = deadline - time.monotonic()
|
|
125
|
+
if remaining <= 0:
|
|
126
|
+
raise TimeoutError("CN_PROC sent no subscription acknowledgement; process events may be disabled or this PID namespace may not be the kernel's initial namespace")
|
|
127
|
+
sock.settimeout(remaining)
|
|
128
|
+
packet = sock.recv(65536)
|
|
129
|
+
try: self._parse_proc_packet(packet)
|
|
130
|
+
except (ValueError, struct.error, OSError) as exc:
|
|
131
|
+
self.health["errors"].append(f"malformed process event during startup: {exc}")
|
|
132
|
+
self._record_native_loss("cn_proc_malformed_packet", count=None)
|
|
133
|
+
if self.health["process"] in (CollectorStatus.PERMISSION_DENIED.value, CollectorStatus.UNAVAILABLE.value): break
|
|
134
|
+
if self.health["process"] != "available":
|
|
135
|
+
sock.close()
|
|
136
|
+
self._sockets.remove(sock)
|
|
137
|
+
return
|
|
138
|
+
sock.settimeout(.5)
|
|
139
|
+
thread = threading.Thread(target=self._process_loop, args=(sock,), daemon=True, name="nativerelay-proc")
|
|
140
|
+
thread.start(); self._threads.append(thread)
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
self._failure("process", exc)
|
|
143
|
+
|
|
144
|
+
def _process_loop(self, sock):
|
|
145
|
+
while not self._stop.is_set():
|
|
146
|
+
try: packet = sock.recv(65536)
|
|
147
|
+
except socket.timeout: continue
|
|
148
|
+
except OSError:
|
|
149
|
+
if not self._stop.is_set(): self._failure("process", OSError("process connector socket closed"))
|
|
150
|
+
break
|
|
151
|
+
try: self._parse_proc_packet(packet)
|
|
152
|
+
except (ValueError, struct.error, OSError) as exc:
|
|
153
|
+
self.health["errors"].append(f"malformed process event: {exc}")
|
|
154
|
+
self._record_native_loss("cn_proc_malformed_packet", count=None)
|
|
155
|
+
|
|
156
|
+
def _parse_proc_packet(self, packet):
|
|
157
|
+
# Netlink header (16), connector header (20), then proc_event data.
|
|
158
|
+
offset = 0
|
|
159
|
+
while offset < len(packet):
|
|
160
|
+
if len(packet) - offset < 16: raise ValueError("truncated netlink header")
|
|
161
|
+
nl_len, nl_type, _, _, _ = struct.unpack_from("=IHHII", packet, offset)
|
|
162
|
+
if nl_len < 16 or offset + nl_len > len(packet): raise ValueError("invalid netlink message length")
|
|
163
|
+
if nl_type == NLMSG_OVERRUN:
|
|
164
|
+
self._record_native_loss("cn_proc_netlink_overrun", count=None)
|
|
165
|
+
offset += (nl_len + 3) & ~3
|
|
166
|
+
continue
|
|
167
|
+
if len(packet) - offset < 36 or nl_len < 36: raise ValueError("truncated netlink connector message")
|
|
168
|
+
cn = offset + 16
|
|
169
|
+
idx, val = struct.unpack_from("=II", packet, cn)
|
|
170
|
+
payload_len = struct.unpack_from("=H", packet, cn + 16)[0]
|
|
171
|
+
if payload_len > nl_len - 36: raise ValueError("connector payload exceeds netlink message")
|
|
172
|
+
if idx == CN_IDX_PROC and val == CN_VAL_PROC and payload_len >= 16:
|
|
173
|
+
body = cn + 20
|
|
174
|
+
what = struct.unpack_from("=I", packet, body)[0]
|
|
175
|
+
data = body + 16
|
|
176
|
+
if what == 0 and payload_len >= 20:
|
|
177
|
+
error = struct.unpack_from("=i", packet, data)[0]
|
|
178
|
+
if error:
|
|
179
|
+
code = abs(error)
|
|
180
|
+
self._failure("process", OSError(code, os.strerror(code)))
|
|
181
|
+
else:
|
|
182
|
+
self.health["process"] = "available"
|
|
183
|
+
self._set_status((EventType.PROCESS_STARTED, EventType.PROCESS_EXITED), CollectorStatus.AVAILABLE, "Linux NETLINK_CONNECTOR/CN_PROC notifications acknowledged")
|
|
184
|
+
elif what == PROC_EVENT_FORK and payload_len >= 32:
|
|
185
|
+
_, parent_tgid, child_pid, child_tgid = struct.unpack_from("=IIII", packet, data)
|
|
186
|
+
if child_pid == child_tgid:
|
|
187
|
+
self._emit_process(EventType.PROCESS_STARTED, child_tgid, parent_tgid)
|
|
188
|
+
elif what == PROC_EVENT_EXIT and payload_len >= 32:
|
|
189
|
+
pid, tgid, code, sig = struct.unpack_from("=IIII", packet, data)
|
|
190
|
+
if pid == tgid:
|
|
191
|
+
self._emit_process(EventType.PROCESS_EXITED, tgid, None,
|
|
192
|
+
{"exitStatus": code, "exitSignal": sig, "leaderThreadExit": True})
|
|
193
|
+
offset += nl_len
|
|
194
|
+
def _process_info(self, pid, parent_pid):
|
|
195
|
+
proc = Path("/proc") / str(pid)
|
|
196
|
+
try:
|
|
197
|
+
stat = (proc / "stat").read_text()
|
|
198
|
+
# comm may contain spaces and ')' characters; fields after final ')' are stable.
|
|
199
|
+
tail = stat[stat.rfind(")") + 2:].split()
|
|
200
|
+
parent_pid = int(tail[1]) if parent_pid is None and len(tail) > 1 else parent_pid
|
|
201
|
+
except (OSError, ValueError, IndexError): pass
|
|
202
|
+
try: executable = os.readlink(proc / "exe")
|
|
203
|
+
except OSError: executable = None
|
|
204
|
+
command = None
|
|
205
|
+
if self.include_command:
|
|
206
|
+
try: command = tuple((proc / "cmdline").read_bytes().decode(errors="replace").rstrip("\0").split("\0"))
|
|
207
|
+
except OSError: pass
|
|
208
|
+
return Process(pid=pid, parent_pid=parent_pid, executable=executable, command=command)
|
|
209
|
+
|
|
210
|
+
def _emit_process(self, typ, pid, ppid, metadata=None):
|
|
211
|
+
self._publish(Event(typ, "linux", self.name, self._process_info(pid, ppid), metadata=metadata or {}))
|
|
212
|
+
|
|
213
|
+
def _start_filesystem(self):
|
|
214
|
+
try:
|
|
215
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
216
|
+
init = libc.fanotify_init
|
|
217
|
+
init.argtypes = [ctypes.c_uint, ctypes.c_uint]
|
|
218
|
+
init.restype = ctypes.c_int
|
|
219
|
+
fd = init(FAN_CLOEXEC | FAN_CLASS_NOTIF | FAN_NONBLOCK, os.O_RDONLY | os.O_LARGEFILE if hasattr(os, "O_LARGEFILE") else os.O_RDONLY)
|
|
220
|
+
if fd < 0: self._raise_errno()
|
|
221
|
+
self._fanotify_fd = fd
|
|
222
|
+
mark = libc.fanotify_mark
|
|
223
|
+
mark.argtypes = [ctypes.c_int, ctypes.c_uint, ctypes.c_uint64, ctypes.c_int, ctypes.c_char_p]
|
|
224
|
+
mark.restype = ctypes.c_int
|
|
225
|
+
mountpoint = self._mountpoint(self.scope)
|
|
226
|
+
if mark(fd, FAN_MARK_ADD | FAN_MARK_MOUNT, FAN_OPEN | FAN_MODIFY, -100, os.fsencode(mountpoint)) < 0:
|
|
227
|
+
os.close(fd); self._raise_errno()
|
|
228
|
+
self._sockets.append(fd)
|
|
229
|
+
self.health["filesystem"] = "available"
|
|
230
|
+
self._set_status((EventType.FILE_OPENED, EventType.FILE_MODIFIED), CollectorStatus.AVAILABLE, f"fanotify mount notifications filtered to {self.scope}")
|
|
231
|
+
thread = threading.Thread(target=self._fanotify_loop, daemon=True, name="nativerelay-fanotify")
|
|
232
|
+
thread.start(); self._threads.append(thread)
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
self._failure("filesystem", exc)
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _raise_errno():
|
|
238
|
+
code = ctypes.get_errno()
|
|
239
|
+
raise OSError(code, os.strerror(code))
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _mountpoint(path):
|
|
243
|
+
best = Path("/")
|
|
244
|
+
for line in Path("/proc/self/mountinfo").read_text().splitlines():
|
|
245
|
+
fields = line.split()
|
|
246
|
+
if len(fields) > 4:
|
|
247
|
+
decoded = re.sub(r"\\([0-7]{3})", lambda match: chr(int(match.group(1), 8)), fields[4])
|
|
248
|
+
try: mount = Path(decoded).resolve()
|
|
249
|
+
except OSError: continue
|
|
250
|
+
if path == mount or mount in path.parents:
|
|
251
|
+
if len(str(mount)) > len(str(best)): best = mount
|
|
252
|
+
return best
|
|
253
|
+
|
|
254
|
+
def _fanotify_loop(self):
|
|
255
|
+
poller = select.poll()
|
|
256
|
+
poller.register(self._fanotify_fd, select.POLLIN | select.POLLERR | select.POLLHUP)
|
|
257
|
+
while not self._stop.is_set():
|
|
258
|
+
ready = poller.poll(500)
|
|
259
|
+
if not ready: continue
|
|
260
|
+
try: data = os.read(self._fanotify_fd, 65536)
|
|
261
|
+
except BlockingIOError: continue
|
|
262
|
+
except OSError:
|
|
263
|
+
if not self._stop.is_set(): self._failure("filesystem", OSError("fanotify descriptor closed"))
|
|
264
|
+
return
|
|
265
|
+
self._parse_fanotify_data(data)
|
|
266
|
+
|
|
267
|
+
def _parse_fanotify_data(self, data):
|
|
268
|
+
offset = 0
|
|
269
|
+
while offset + FANOTIFY_METADATA_LEN <= len(data):
|
|
270
|
+
try:
|
|
271
|
+
length, version, _, metadata_len, mask, event_fd, pid = struct.unpack_from(FAN_EVENT_METADATA_FMT, data, offset)
|
|
272
|
+
if length < FANOTIFY_METADATA_LEN or offset + length > len(data): raise ValueError("invalid fanotify event length")
|
|
273
|
+
if version != FANOTIFY_METADATA_VERSION or metadata_len < FANOTIFY_METADATA_LEN or metadata_len > length: raise ValueError("unsupported fanotify metadata version")
|
|
274
|
+
if mask & FAN_Q_OVERFLOW:
|
|
275
|
+
self._record_native_loss("fanotify_queue_overflow", count=None)
|
|
276
|
+
elif event_fd >= 0:
|
|
277
|
+
self._handle_file_event(mask, event_fd, pid)
|
|
278
|
+
offset += length
|
|
279
|
+
except (ValueError, struct.error) as exc:
|
|
280
|
+
self.health["errors"].append(f"malformed fanotify event: {exc}")
|
|
281
|
+
self._record_native_loss("fanotify_malformed_event", count=None)
|
|
282
|
+
break
|
|
283
|
+
if offset < len(data) and len(data) - offset < FANOTIFY_METADATA_LEN:
|
|
284
|
+
self.health["errors"].append("truncated fanotify metadata")
|
|
285
|
+
self._record_native_loss("fanotify_truncated_metadata", count=None)
|
|
286
|
+
def _handle_file_event(self, mask, fd, pid):
|
|
287
|
+
try:
|
|
288
|
+
path = Path(os.readlink(f"/proc/self/fd/{fd}")).resolve(strict=False)
|
|
289
|
+
if path != self.scope and self.scope not in path.parents: return
|
|
290
|
+
if mask & FAN_OPEN:
|
|
291
|
+
self._publish(Event(EventType.FILE_OPENED, "linux", self.name, self._process_info(pid, None), Resource("file", str(path))))
|
|
292
|
+
if mask & FAN_MODIFY:
|
|
293
|
+
self._publish(Event(EventType.FILE_MODIFIED, "linux", self.name, self._process_info(pid, None), Resource("file", str(path))))
|
|
294
|
+
except (OSError, ValueError) as exc:
|
|
295
|
+
self.health["errors"].append(f"file event resolution: {exc}")
|
|
296
|
+
lost = int(bool(mask & FAN_OPEN)) + int(bool(mask & FAN_MODIFY))
|
|
297
|
+
if lost: self._record_native_loss("fanotify_path_resolution", count=lost)
|
|
298
|
+
finally:
|
|
299
|
+
try: os.close(fd)
|
|
300
|
+
except OSError: pass
|
|
301
|
+
|
|
302
|
+
def _record_native_loss(self, reason, *, count):
|
|
303
|
+
"""Record detected native loss and reserve a collector sequence marker."""
|
|
304
|
+
with self._publish_lock:
|
|
305
|
+
sequence = self._sequence
|
|
306
|
+
self._sequence += 1
|
|
307
|
+
if self._stream is not None:
|
|
308
|
+
self._stream.report_loss(self.name, reason, count=count, sequence=sequence)
|
|
309
|
+
|
|
310
|
+
def _publish(self, event):
|
|
311
|
+
with self._publish_lock:
|
|
312
|
+
sequenced = replace(event, sequence=self._sequence)
|
|
313
|
+
self._sequence += 1
|
|
314
|
+
if self._stream is not None and not self._stream.publish(sequenced):
|
|
315
|
+
self.health["dropped"] += 1
|
|
316
|
+
|
|
317
|
+
def _set_status(self, event_types, status, reason):
|
|
318
|
+
self._caps = tuple(Capability(c.event_type, status if c.event_type in event_types else c.status,
|
|
319
|
+
reason if c.event_type in event_types else c.reason, c.scope, c.attribution) for c in self._caps)
|
nativerelay/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"""NativeRelay platform-neutral event API."""
|
|
2
|
+
from .model import Capability, CollectorStatus, Event, EventType, Process, Resource
|
|
3
|
+
from .stream import EventStream, StreamClosed
|
|
4
|
+
__all__ = ["Capability", "CollectorStatus", "Event", "EventType", "Process", "Resource", "EventStream", "StreamClosed"]
|
nativerelay/build.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tiny offline PEP 517 wheel builder; runtime remains standard-library only."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import base64
|
|
4
|
+
import csv
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
from zipfile import ZIP_DEFLATED, ZipFile
|
|
8
|
+
|
|
9
|
+
NAME = "nativerelay"
|
|
10
|
+
VERSION = "0.1.0"
|
|
11
|
+
|
|
12
|
+
def get_requires_for_build_wheel(config_settings=None):
|
|
13
|
+
return []
|
|
14
|
+
|
|
15
|
+
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
|
|
16
|
+
output = Path(wheel_directory)
|
|
17
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
filename = f"{NAME}-{VERSION}-py3-none-any.whl"
|
|
19
|
+
dist = f"{NAME}-{VERSION}.dist-info"
|
|
20
|
+
files = []
|
|
21
|
+
for package in (Path("nativerelay"), Path("collectors")):
|
|
22
|
+
for path in package.rglob("*.py"):
|
|
23
|
+
if "__pycache__" not in path.parts:
|
|
24
|
+
files.append(path)
|
|
25
|
+
record = []
|
|
26
|
+
with ZipFile(output / filename, "w", ZIP_DEFLATED) as wheel:
|
|
27
|
+
for path in files:
|
|
28
|
+
content = path.read_bytes()
|
|
29
|
+
wheel.writestr(path.as_posix(), content)
|
|
30
|
+
record.append(_record_row(path.as_posix(), content))
|
|
31
|
+
metadata = f"{dist}/METADATA"
|
|
32
|
+
content = b"Metadata-Version: 2.1\nName: nativerelay\nVersion: 0.1.0\nRequires-Python: >=3.11\nSummary: Local-first normalized native OS telemetry\n\n"
|
|
33
|
+
wheel.writestr(metadata, content); record.append(_record_row(metadata, content))
|
|
34
|
+
wheel_file = f"{dist}/WHEEL"
|
|
35
|
+
content = b"Wheel-Version: 1.0\nGenerator: nativerelay.build\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
|
|
36
|
+
wheel.writestr(wheel_file, content); record.append(_record_row(wheel_file, content))
|
|
37
|
+
entrypoints = f"{dist}/entry_points.txt"
|
|
38
|
+
content = b"[console_scripts]\nnativerelay = nativerelay.cli:main\n"
|
|
39
|
+
wheel.writestr(entrypoints, content); record.append(_record_row(entrypoints, content))
|
|
40
|
+
record_path = f"{dist}/RECORD"
|
|
41
|
+
record.append((record_path, "", ""))
|
|
42
|
+
stream = io.StringIO(newline="")
|
|
43
|
+
csv.writer(stream, lineterminator="\n").writerows(record)
|
|
44
|
+
wheel.writestr(record_path, stream.getvalue())
|
|
45
|
+
return filename
|
|
46
|
+
|
|
47
|
+
def _record_row(path, content):
|
|
48
|
+
digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii")
|
|
49
|
+
return (path, f"sha256={digest}", str(len(content)))
|
nativerelay/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""NativeRelay local development CLI."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from collectors.linux import LinuxCollector
|
|
7
|
+
from .stream import EventStream
|
|
8
|
+
|
|
9
|
+
def main(argv=None):
|
|
10
|
+
parser = argparse.ArgumentParser(prog="nativerelay", description="Observe local Linux kernel events")
|
|
11
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
12
|
+
observe = commands.add_parser("observe", help="stream process events and scoped file open/write events")
|
|
13
|
+
observe.add_argument("--scope", required=True, help="existing directory to include in file events")
|
|
14
|
+
observe.add_argument("--json", action="store_true", help="emit one JSON event per line")
|
|
15
|
+
observe.add_argument("--include-command", action="store_true", help="include argv (may contain sensitive data)")
|
|
16
|
+
commands.add_parser("capabilities", help="show collector capabilities for a scope")
|
|
17
|
+
capabilities = commands.choices["capabilities"]
|
|
18
|
+
capabilities.add_argument("--scope", required=True)
|
|
19
|
+
args = parser.parse_args(argv)
|
|
20
|
+
try: collector = LinuxCollector(args.scope, include_command=getattr(args, "include_command", False))
|
|
21
|
+
except (OSError, ValueError) as exc:
|
|
22
|
+
print(f"nativerelay: {exc}", file=sys.stderr); return 2
|
|
23
|
+
if args.command == "capabilities":
|
|
24
|
+
collector.start(EventStream(1))
|
|
25
|
+
try:
|
|
26
|
+
print(json.dumps({"platform": "linux", "collector": collector.name,
|
|
27
|
+
"scope": str(collector.scope), "health": collector.status,
|
|
28
|
+
"capabilities": [
|
|
29
|
+
{**asdict(c), "event_type": c.event_type.value, "status": c.status.value}
|
|
30
|
+
for c in collector.capabilities]}, indent=2))
|
|
31
|
+
finally: collector.stop()
|
|
32
|
+
return 0
|
|
33
|
+
stream = EventStream()
|
|
34
|
+
try:
|
|
35
|
+
collector.start(stream)
|
|
36
|
+
print("NativeRelay Linux observe (Ctrl-C to stop)", file=sys.stderr)
|
|
37
|
+
last_dropped = -1
|
|
38
|
+
last_loss_generation = -1
|
|
39
|
+
while True:
|
|
40
|
+
try: event = stream.receive(timeout=1)
|
|
41
|
+
except TimeoutError:
|
|
42
|
+
status = collector.status
|
|
43
|
+
if status["errors"] or status["dropped"] != last_dropped or status["loss_generation"] != last_loss_generation:
|
|
44
|
+
print(json.dumps({"collector_status": status}), file=sys.stderr)
|
|
45
|
+
collector.health["errors"].clear()
|
|
46
|
+
last_dropped = status["dropped"]
|
|
47
|
+
last_loss_generation = status["loss_generation"]
|
|
48
|
+
continue
|
|
49
|
+
print(event.to_json() if args.json else _human(event), flush=True)
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
return 0
|
|
52
|
+
finally: collector.stop()
|
|
53
|
+
|
|
54
|
+
def _human(event):
|
|
55
|
+
proc = f"pid={event.process.pid}"
|
|
56
|
+
if event.process.parent_pid is not None: proc += f" ppid={event.process.parent_pid}"
|
|
57
|
+
if event.process.executable: proc += f" exe={event.process.executable}"
|
|
58
|
+
resource = f" {event.resource.path}" if event.resource else ""
|
|
59
|
+
metadata = " " + json.dumps(event.metadata) if event.metadata else ""
|
|
60
|
+
return f"{event.timestamp} {event.type.value} {proc}{resource}{metadata}"
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__": raise SystemExit(main())
|
nativerelay/collector.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Platform-neutral collector contract."""
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from .model import Capability
|
|
4
|
+
from .stream import EventStream
|
|
5
|
+
|
|
6
|
+
class Collector(ABC):
|
|
7
|
+
@property
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def capabilities(self) -> tuple[Capability, ...]: ...
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def start(self, stream: EventStream) -> None: ...
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def stop(self) -> None: ...
|
nativerelay/model.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Version 1 normalized event and capability model.
|
|
2
|
+
|
|
3
|
+
Timestamps are timezone-aware ISO-8601 instants. ``sequence`` is optional and, when a
|
|
4
|
+
collector supplies it, orders events as normalized by that collector only.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from enum import Enum
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
class EventType(str, Enum):
|
|
16
|
+
PROCESS_STARTED = "process.started"
|
|
17
|
+
PROCESS_EXITED = "process.exited"
|
|
18
|
+
FILE_OPENED = "file.opened"
|
|
19
|
+
FILE_CREATED = "file.created"
|
|
20
|
+
FILE_MODIFIED = "file.modified"
|
|
21
|
+
FILE_DELETED = "file.deleted"
|
|
22
|
+
FILE_RENAMED = "file.renamed"
|
|
23
|
+
|
|
24
|
+
class CollectorStatus(str, Enum):
|
|
25
|
+
SUPPORTED = "supported"
|
|
26
|
+
AVAILABLE = "available"
|
|
27
|
+
DEGRADED = "degraded"
|
|
28
|
+
PERMISSION_DENIED = "permission_denied"
|
|
29
|
+
UNAVAILABLE = "unavailable"
|
|
30
|
+
UNSUPPORTED = "unsupported"
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Process:
|
|
34
|
+
pid: int
|
|
35
|
+
parent_pid: int | None = None
|
|
36
|
+
executable: str | None = None
|
|
37
|
+
command: tuple[str, ...] | None = None
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
if not isinstance(self.pid, int) or self.pid <= 0 or (self.parent_pid is not None and (not isinstance(self.parent_pid, int) or self.parent_pid < 0)):
|
|
40
|
+
raise ValueError("process IDs must be positive (parent may be zero)")
|
|
41
|
+
if self.executable is not None and not isinstance(self.executable, str): raise ValueError("executable must be a string")
|
|
42
|
+
if self.command is not None and not all(isinstance(x, str) for x in self.command):
|
|
43
|
+
raise ValueError("command must contain strings")
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Resource:
|
|
47
|
+
type: str
|
|
48
|
+
path: str
|
|
49
|
+
def __post_init__(self):
|
|
50
|
+
if not isinstance(self.type, str) or not isinstance(self.path, str) or not self.type or not self.path:
|
|
51
|
+
raise ValueError("resource type and path are required")
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Event:
|
|
55
|
+
type: EventType
|
|
56
|
+
platform: str
|
|
57
|
+
collector: str
|
|
58
|
+
process: Process
|
|
59
|
+
resource: Resource | None = None
|
|
60
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
61
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
evidence: str = "observed"
|
|
63
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
64
|
+
sequence: int | None = None
|
|
65
|
+
def __post_init__(self):
|
|
66
|
+
if not isinstance(self.type, EventType): object.__setattr__(self, "type", EventType(self.type))
|
|
67
|
+
if not isinstance(self.process, Process): raise ValueError("process must be a Process")
|
|
68
|
+
if self.resource is not None and not isinstance(self.resource, Resource): raise ValueError("resource must be a Resource")
|
|
69
|
+
if not all(isinstance(value, str) and value for value in (self.platform, self.collector, self.id)):
|
|
70
|
+
raise ValueError("platform, collector, and id are required")
|
|
71
|
+
if self.evidence not in ("observed", "derived"): raise ValueError("evidence must be observed or derived")
|
|
72
|
+
try:
|
|
73
|
+
parsed_timestamp = datetime.fromisoformat(self.timestamp.replace("Z", "+00:00"))
|
|
74
|
+
if parsed_timestamp.utcoffset() is None: raise ValueError("timezone required")
|
|
75
|
+
except (ValueError, AttributeError) as exc: raise ValueError("timestamp must be ISO-8601 with timezone") from exc
|
|
76
|
+
if self.sequence is not None and (not isinstance(self.sequence, int) or self.sequence < 0):
|
|
77
|
+
raise ValueError("sequence must be a non-negative integer or None")
|
|
78
|
+
if not isinstance(self.metadata, dict): raise ValueError("metadata must be an object")
|
|
79
|
+
try: json.dumps(self.metadata)
|
|
80
|
+
except (TypeError, ValueError) as exc: raise ValueError("metadata must be JSON serializable") from exc
|
|
81
|
+
@staticmethod
|
|
82
|
+
def stable_id(collector: str, boot_id: str, sequence: int) -> str:
|
|
83
|
+
raw = f"{collector}\0{boot_id}\0{sequence}".encode()
|
|
84
|
+
return hashlib.sha256(raw).hexdigest()
|
|
85
|
+
def to_dict(self):
|
|
86
|
+
return {"id": self.id, "timestamp": self.timestamp, "type": self.type.value,
|
|
87
|
+
"platform": self.platform, "collector": self.collector, "process": asdict(self.process),
|
|
88
|
+
"resource": asdict(self.resource) if self.resource else None,
|
|
89
|
+
"metadata": self.metadata, "evidence": self.evidence, "sequence": self.sequence, "schema_version": 1}
|
|
90
|
+
def to_json(self): return json.dumps(self.to_dict(), separators=(",", ":"), ensure_ascii=True)
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Capability:
|
|
94
|
+
event_type: EventType
|
|
95
|
+
status: CollectorStatus
|
|
96
|
+
reason: str
|
|
97
|
+
scope: str
|
|
98
|
+
attribution: str
|
|
99
|
+
def __post_init__(self):
|
|
100
|
+
if not isinstance(self.event_type, EventType): object.__setattr__(self, "event_type", EventType(self.event_type))
|
|
101
|
+
if not isinstance(self.status, CollectorStatus): object.__setattr__(self, "status", CollectorStatus(self.status))
|
|
102
|
+
if not self.reason or not self.scope or not self.attribution: raise ValueError("capability reason, scope, and attribution are required")
|
|
103
|
+
|
|
104
|
+
def decode_event(data: str) -> Event:
|
|
105
|
+
try:
|
|
106
|
+
raw = json.loads(data)
|
|
107
|
+
return Event(type=EventType(raw["type"]), platform=raw["platform"], collector=raw["collector"],
|
|
108
|
+
process=Process(**{**raw["process"], "command": tuple(raw["process"]["command"]) if raw["process"].get("command") is not None else None}),
|
|
109
|
+
resource=Resource(**raw["resource"]) if raw.get("resource") else None,
|
|
110
|
+
timestamp=raw["timestamp"], metadata=raw.get("metadata", {}), evidence=raw["evidence"], id=raw["id"],
|
|
111
|
+
sequence=raw.get("sequence"))
|
|
112
|
+
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
113
|
+
raise ValueError(f"invalid NativeRelay event: {exc}") from exc
|
nativerelay/stream.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Bounded synchronous event stream shared by collectors."""
|
|
2
|
+
from queue import Queue, Full, Empty
|
|
3
|
+
from threading import Lock
|
|
4
|
+
from .model import Event
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class StreamClosed(RuntimeError):
|
|
8
|
+
"""Raised after a producer has closed the stream and queued events drain."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventStream:
|
|
12
|
+
def __init__(self, capacity=1024):
|
|
13
|
+
if capacity <= 0: raise ValueError("capacity must be positive")
|
|
14
|
+
self._queue = Queue(maxsize=capacity)
|
|
15
|
+
self.dropped = 0
|
|
16
|
+
self._closed = False
|
|
17
|
+
self._lock = Lock()
|
|
18
|
+
self._close_error = None
|
|
19
|
+
self._loss_generation = 0
|
|
20
|
+
self._losses = {}
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def closed(self):
|
|
24
|
+
with self._lock: return self._closed
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def error(self):
|
|
28
|
+
with self._lock: return self._close_error
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def loss_generation(self):
|
|
32
|
+
"""Monotonic counter; changes whenever a producer reports detected loss."""
|
|
33
|
+
with self._lock: return self._loss_generation
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def losses(self):
|
|
37
|
+
"""Copy of loss totals grouped by source and reason."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
return {source: {reason: dict(totals) for reason, totals in reasons.items()}
|
|
40
|
+
for source, reasons in self._losses.items()}
|
|
41
|
+
|
|
42
|
+
def _record_loss_locked(self, source, reason, count, sequence):
|
|
43
|
+
totals = self._losses.setdefault(source, {}).setdefault(reason, {
|
|
44
|
+
"occurrences": 0, "known_dropped": 0, "unknown_count": False,
|
|
45
|
+
"last_sequence": None,
|
|
46
|
+
})
|
|
47
|
+
totals["occurrences"] += 1
|
|
48
|
+
if count is None:
|
|
49
|
+
totals["unknown_count"] = True
|
|
50
|
+
else:
|
|
51
|
+
totals["known_dropped"] += count
|
|
52
|
+
if sequence is not None: totals["last_sequence"] = sequence
|
|
53
|
+
self._loss_generation += 1
|
|
54
|
+
|
|
55
|
+
def report_loss(self, source, reason, *, count=None, sequence=None):
|
|
56
|
+
"""Report an observed loss; count=None means the lost-event count is unknown."""
|
|
57
|
+
if not isinstance(source, str) or not source: raise ValueError("loss source is required")
|
|
58
|
+
if not isinstance(reason, str) or not reason: raise ValueError("loss reason is required")
|
|
59
|
+
if count is not None and (not isinstance(count, int) or count < 0):
|
|
60
|
+
raise ValueError("loss count must be a non-negative integer or None")
|
|
61
|
+
if sequence is not None and (not isinstance(sequence, int) or sequence < 0):
|
|
62
|
+
raise ValueError("loss sequence must be a non-negative integer or None")
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._record_loss_locked(source, reason, count, sequence)
|
|
65
|
+
|
|
66
|
+
def publish(self, event: Event):
|
|
67
|
+
if not isinstance(event, Event): raise TypeError("event must be a NativeRelay Event")
|
|
68
|
+
with self._lock:
|
|
69
|
+
if self._closed:
|
|
70
|
+
self.dropped += 1
|
|
71
|
+
self._record_loss_locked(event.collector, "event_stream_closed", 1, event.sequence)
|
|
72
|
+
return False
|
|
73
|
+
try: self._queue.put_nowait(event); return True
|
|
74
|
+
except Full:
|
|
75
|
+
self.dropped += 1
|
|
76
|
+
self._record_loss_locked(event.collector, "event_stream_full", 1, event.sequence)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def close(self, error: BaseException | None = None):
|
|
80
|
+
"""Stop accepting events; buffered events remain available to consumers."""
|
|
81
|
+
with self._lock:
|
|
82
|
+
if self._closed: return
|
|
83
|
+
self._closed = True
|
|
84
|
+
self._close_error = error
|
|
85
|
+
# Wake a blocked receiver if the queue is empty. A full queue already
|
|
86
|
+
# guarantees that no receiver can be blocked waiting for an event.
|
|
87
|
+
try: self._queue.put_nowait(_CLOSED)
|
|
88
|
+
except Full: pass
|
|
89
|
+
|
|
90
|
+
def receive(self, timeout=None):
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self._closed and self._queue.empty():
|
|
93
|
+
raise StreamClosed("NativeRelay event stream is closed")
|
|
94
|
+
try: item = self._queue.get(timeout=timeout)
|
|
95
|
+
except Empty as exc:
|
|
96
|
+
if self.closed: raise StreamClosed("NativeRelay event stream is closed") from exc
|
|
97
|
+
raise TimeoutError("no NativeRelay event available") from exc
|
|
98
|
+
if item is _CLOSED:
|
|
99
|
+
# Keep closure visible to every later receive call.
|
|
100
|
+
try: self._queue.put_nowait(_CLOSED)
|
|
101
|
+
except Full: pass
|
|
102
|
+
raise StreamClosed("NativeRelay event stream is closed")
|
|
103
|
+
return item
|
|
104
|
+
|
|
105
|
+
def __iter__(self):
|
|
106
|
+
while True:
|
|
107
|
+
try: yield self.receive()
|
|
108
|
+
except StreamClosed: return
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_CLOSED = object()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
nativerelay/collector.py,sha256=jPDHfzCnDK7hZlrRUijUBuwY1pKrgqEpH-j3arHQAWs,382
|
|
2
|
+
nativerelay/cli.py,sha256=nJ4DErru3tYf6nAbvzGUAkb6Nfj2aDBCcRBkTYlPp9E,3211
|
|
3
|
+
nativerelay/model.py,sha256=B2877ueYtHhffLldFd_7J-BdoSyY-uP5ognPVdG442Q,5773
|
|
4
|
+
nativerelay/stream.py,sha256=uHixaWvliAufV0kwbSz7r1rh4eUOnzwYlXJhquSsSR8,4514
|
|
5
|
+
nativerelay/build.py,sha256=i3HGOBGNBrUEXamSFgMwvPW-P-S6y1xdZygkoD-yjXQ,2243
|
|
6
|
+
nativerelay/__init__.py,sha256=ybvXpAmxt80upZF6drELWZRem-8iXa9YIZC4C9_mMw0,296
|
|
7
|
+
collectors/__init__.py,sha256=QXgA7aS-PUeEorvGCuQe96rSHIE730bEpPPzN9r0XJc,34
|
|
8
|
+
collectors/linux/collector.py,sha256=43_nm9KPeTa-I8gd8rwE0t6gosDFeltIXHnns2UcO8c,16789
|
|
9
|
+
collectors/linux/__init__.py,sha256=IuTIryxNWK1r16SJlZNvwIzcMhQoqsishl3-cqZVt-Y,107
|
|
10
|
+
nativerelay-0.1.0.dist-info/METADATA,sha256=PCBP-mQfNdL80H0e6fNzXeGduc9mcIv9UXtV2H60JrQ,132
|
|
11
|
+
nativerelay-0.1.0.dist-info/WHEEL,sha256=z2Hx0MKflNlZIAkP5GUTPqUXXNrsUKmC72d9FVdGwY0,88
|
|
12
|
+
nativerelay-0.1.0.dist-info/entry_points.txt,sha256=FDtVrUt_aeMn4jVO4p67jYka9krFId8KR21ooTfEn9s,53
|
|
13
|
+
nativerelay-0.1.0.dist-info/RECORD,,
|