cmdscope 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.
- cmdscope/__init__.py +12 -0
- cmdscope/__main__.py +8 -0
- cmdscope/analysis/__init__.py +1 -0
- cmdscope/analysis/comparison.py +134 -0
- cmdscope/analysis/summary.py +37 -0
- cmdscope/cli.py +555 -0
- cmdscope/collectors/__init__.py +1 -0
- cmdscope/collectors/filesystem.py +370 -0
- cmdscope/collectors/network.py +196 -0
- cmdscope/collectors/processes.py +365 -0
- cmdscope/config.py +461 -0
- cmdscope/core/__init__.py +1 -0
- cmdscope/core/collector.py +48 -0
- cmdscope/core/engine.py +514 -0
- cmdscope/core/events.py +113 -0
- cmdscope/core/ignore.py +56 -0
- cmdscope/core/registry.py +33 -0
- cmdscope/core/session.py +81 -0
- cmdscope/diagnostics.py +183 -0
- cmdscope/errors.py +36 -0
- cmdscope/platforms/__init__.py +57 -0
- cmdscope/platforms/base.py +140 -0
- cmdscope/platforms/linux.py +28 -0
- cmdscope/platforms/macos.py +26 -0
- cmdscope/platforms/windows.py +28 -0
- cmdscope/privacy.py +143 -0
- cmdscope/py.typed +0 -0
- cmdscope/reports/__init__.py +1 -0
- cmdscope/reports/html.py +279 -0
- cmdscope/reports/json_report.py +133 -0
- cmdscope/reports/terminal.py +429 -0
- cmdscope/storage/__init__.py +6 -0
- cmdscope/storage/sqlite.py +469 -0
- cmdscope/storage/writer.py +105 -0
- cmdscope-0.1.0.dist-info/METADATA +353 -0
- cmdscope-0.1.0.dist-info/RECORD +40 -0
- cmdscope-0.1.0.dist-info/WHEEL +5 -0
- cmdscope-0.1.0.dist-info/entry_points.txt +2 -0
- cmdscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- cmdscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Process-tree collector based on bounded psutil polling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
import psutil
|
|
12
|
+
|
|
13
|
+
from cmdscope.core.collector import CollectorContext
|
|
14
|
+
from cmdscope.core.events import (
|
|
15
|
+
CollectorState,
|
|
16
|
+
CollectorStatus,
|
|
17
|
+
Event,
|
|
18
|
+
EventKind,
|
|
19
|
+
utc_now,
|
|
20
|
+
)
|
|
21
|
+
from cmdscope.core.registry import ProcessRegistry
|
|
22
|
+
from cmdscope.privacy import sanitize_command
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class _ProcessInfo:
|
|
27
|
+
pid: int
|
|
28
|
+
ppid: int | None
|
|
29
|
+
name: str
|
|
30
|
+
exe: str | None
|
|
31
|
+
cmdline: tuple[str, ...]
|
|
32
|
+
create_time: float | None
|
|
33
|
+
first_seen: datetime
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProcessCollector:
|
|
37
|
+
"""Track the root process and its descendants.
|
|
38
|
+
|
|
39
|
+
Discovery is poll-based. A child that starts and exits between two polls can
|
|
40
|
+
be missed; that limitation is part of the collector status. PIDs are matched
|
|
41
|
+
with their creation time to avoid attributing a reused PID to the session.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name = "processes"
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, poll_interval: float) -> None:
|
|
47
|
+
self._poll_interval = poll_interval
|
|
48
|
+
self._stop = threading.Event()
|
|
49
|
+
self._lock = threading.RLock()
|
|
50
|
+
self._known: dict[int, _ProcessInfo] = {}
|
|
51
|
+
self._registry = ProcessRegistry()
|
|
52
|
+
self._emit: Callable[[Event], None] | None = None
|
|
53
|
+
self._root_pid = 0
|
|
54
|
+
self._command_name = ""
|
|
55
|
+
self._thread: threading.Thread | None = None
|
|
56
|
+
self._access_denied = False
|
|
57
|
+
self._error: str | None = None
|
|
58
|
+
self._root_vanished = False
|
|
59
|
+
self._started_at: datetime | None = None
|
|
60
|
+
self._events_emitted = 0
|
|
61
|
+
|
|
62
|
+
def set_registry(self, registry: ProcessRegistry) -> None:
|
|
63
|
+
"""Use the session-wide registry shared with the network collector."""
|
|
64
|
+
|
|
65
|
+
self._registry = registry
|
|
66
|
+
|
|
67
|
+
def start(self, context: CollectorContext) -> None:
|
|
68
|
+
self._root_pid = context.root_pid
|
|
69
|
+
self._command_name = context.command_name
|
|
70
|
+
self._registry = context.registry
|
|
71
|
+
self._emit = context.emit
|
|
72
|
+
self._started_at = utc_now()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
process = psutil.Process(self._root_pid)
|
|
76
|
+
self._register(process)
|
|
77
|
+
except psutil.NoSuchProcess:
|
|
78
|
+
self._root_vanished = True
|
|
79
|
+
self._emit_started(
|
|
80
|
+
pid=self._root_pid,
|
|
81
|
+
ppid=None,
|
|
82
|
+
name=self._command_name,
|
|
83
|
+
exe=None,
|
|
84
|
+
cmdline=(),
|
|
85
|
+
create_time=None,
|
|
86
|
+
)
|
|
87
|
+
self._emit_finished(
|
|
88
|
+
pid=self._root_pid,
|
|
89
|
+
name=self._command_name,
|
|
90
|
+
exe=None,
|
|
91
|
+
cmdline=(),
|
|
92
|
+
reason="exited_before_observation",
|
|
93
|
+
first_seen=self._started_at,
|
|
94
|
+
)
|
|
95
|
+
except psutil.AccessDenied as exc:
|
|
96
|
+
self._access_denied = True
|
|
97
|
+
self._error = f"cannot inspect root process: {exc}"
|
|
98
|
+
|
|
99
|
+
self._thread = threading.Thread(
|
|
100
|
+
target=self._poll_loop, name="cmdscope-process-poller", daemon=True
|
|
101
|
+
)
|
|
102
|
+
self._thread.start()
|
|
103
|
+
|
|
104
|
+
def _poll_loop(self) -> None:
|
|
105
|
+
while not self._stop.is_set():
|
|
106
|
+
try:
|
|
107
|
+
self._poll()
|
|
108
|
+
except Exception as exc: # pragma: no cover - defensive thread boundary
|
|
109
|
+
self._error = f"process polling failed: {exc}"
|
|
110
|
+
return
|
|
111
|
+
if self._stop.wait(self._poll_interval):
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
def _poll(self) -> None:
|
|
115
|
+
self._discover_descendants()
|
|
116
|
+
self._reap_gone()
|
|
117
|
+
|
|
118
|
+
def _discover_descendants(self) -> None:
|
|
119
|
+
with self._lock:
|
|
120
|
+
known_pids = list(self._known)
|
|
121
|
+
for pid in known_pids:
|
|
122
|
+
try:
|
|
123
|
+
process = psutil.Process(pid)
|
|
124
|
+
if not self._still_same_process(process, pid):
|
|
125
|
+
continue
|
|
126
|
+
children = process.children(recursive=False)
|
|
127
|
+
except psutil.NoSuchProcess:
|
|
128
|
+
continue
|
|
129
|
+
except psutil.AccessDenied:
|
|
130
|
+
self._access_denied = True
|
|
131
|
+
continue
|
|
132
|
+
for child in children:
|
|
133
|
+
self._register(child)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
root = psutil.Process(self._root_pid)
|
|
137
|
+
for child in root.children(recursive=True):
|
|
138
|
+
self._register(child)
|
|
139
|
+
except psutil.NoSuchProcess:
|
|
140
|
+
return
|
|
141
|
+
except psutil.AccessDenied:
|
|
142
|
+
self._access_denied = True
|
|
143
|
+
|
|
144
|
+
def _register(self, process: psutil.Process) -> None:
|
|
145
|
+
try:
|
|
146
|
+
pid = process.pid
|
|
147
|
+
except psutil.NoSuchProcess:
|
|
148
|
+
return
|
|
149
|
+
with self._lock:
|
|
150
|
+
if pid in self._known:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
ppid: int | None = None
|
|
154
|
+
name = f"pid-{pid}"
|
|
155
|
+
exe: str | None = None
|
|
156
|
+
cmdline_value: list[str] = []
|
|
157
|
+
create_time: float | None = None
|
|
158
|
+
try:
|
|
159
|
+
ppid = process.ppid()
|
|
160
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
161
|
+
ppid = None
|
|
162
|
+
try:
|
|
163
|
+
name = process.name() or name
|
|
164
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
165
|
+
name = f"pid-{pid}"
|
|
166
|
+
try:
|
|
167
|
+
exe = process.exe()
|
|
168
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
169
|
+
exe = None
|
|
170
|
+
try:
|
|
171
|
+
cmdline_value = process.cmdline()
|
|
172
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
173
|
+
cmdline_value = []
|
|
174
|
+
try:
|
|
175
|
+
create_time = process.create_time()
|
|
176
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
177
|
+
create_time = None
|
|
178
|
+
|
|
179
|
+
cmdline = sanitize_command(cmdline_value)
|
|
180
|
+
info = _ProcessInfo(
|
|
181
|
+
pid=pid,
|
|
182
|
+
ppid=ppid,
|
|
183
|
+
name=name,
|
|
184
|
+
exe=exe,
|
|
185
|
+
cmdline=cmdline,
|
|
186
|
+
create_time=create_time,
|
|
187
|
+
first_seen=utc_now(),
|
|
188
|
+
)
|
|
189
|
+
with self._lock:
|
|
190
|
+
if pid in self._known:
|
|
191
|
+
return
|
|
192
|
+
self._known[pid] = info
|
|
193
|
+
self._registry.add(pid, name)
|
|
194
|
+
self._emit_started(
|
|
195
|
+
pid=pid,
|
|
196
|
+
ppid=ppid,
|
|
197
|
+
name=name,
|
|
198
|
+
exe=exe,
|
|
199
|
+
cmdline=cmdline,
|
|
200
|
+
create_time=create_time,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _still_same_process(self, process: psutil.Process, pid: int) -> bool:
|
|
204
|
+
with self._lock:
|
|
205
|
+
info = self._known.get(pid)
|
|
206
|
+
if info is None:
|
|
207
|
+
return False
|
|
208
|
+
try:
|
|
209
|
+
return abs(process.create_time() - (info.create_time or 0.0)) < 0.01
|
|
210
|
+
except psutil.NoSuchProcess:
|
|
211
|
+
return False
|
|
212
|
+
except psutil.AccessDenied:
|
|
213
|
+
self._access_denied = True
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
def _reap_gone(self) -> None:
|
|
217
|
+
with self._lock:
|
|
218
|
+
pids = list(self._known)
|
|
219
|
+
for pid in pids:
|
|
220
|
+
with self._lock:
|
|
221
|
+
info = self._known.get(pid)
|
|
222
|
+
if info is None:
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
process = psutil.Process(pid)
|
|
226
|
+
if process.is_running() and self._still_same_process(process, pid):
|
|
227
|
+
continue
|
|
228
|
+
except psutil.NoSuchProcess:
|
|
229
|
+
pass
|
|
230
|
+
except psutil.AccessDenied:
|
|
231
|
+
self._access_denied = True
|
|
232
|
+
continue
|
|
233
|
+
self._mark_finished(pid, info, reason="no_longer_observed")
|
|
234
|
+
|
|
235
|
+
def _mark_finished(self, pid: int, info: _ProcessInfo, *, reason: str) -> None:
|
|
236
|
+
with self._lock:
|
|
237
|
+
current = self._known.pop(pid, None)
|
|
238
|
+
if current is None:
|
|
239
|
+
return
|
|
240
|
+
self._registry.remove(pid)
|
|
241
|
+
self._emit_finished(
|
|
242
|
+
pid=pid,
|
|
243
|
+
name=info.name,
|
|
244
|
+
exe=info.exe,
|
|
245
|
+
cmdline=info.cmdline,
|
|
246
|
+
reason=reason,
|
|
247
|
+
first_seen=info.first_seen,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _emit_started(
|
|
251
|
+
self,
|
|
252
|
+
*,
|
|
253
|
+
pid: int,
|
|
254
|
+
ppid: int | None,
|
|
255
|
+
name: str,
|
|
256
|
+
exe: str | None,
|
|
257
|
+
cmdline: tuple[str, ...],
|
|
258
|
+
create_time: float | None,
|
|
259
|
+
) -> None:
|
|
260
|
+
self._emit_event(
|
|
261
|
+
Event.create(
|
|
262
|
+
EventKind.PROCESS_STARTED,
|
|
263
|
+
source=self.name,
|
|
264
|
+
payload={
|
|
265
|
+
"pid": pid,
|
|
266
|
+
"ppid": ppid,
|
|
267
|
+
"name": name,
|
|
268
|
+
"exe": exe,
|
|
269
|
+
"cmdline": list(cmdline),
|
|
270
|
+
"command": shlex.join(cmdline),
|
|
271
|
+
"create_time": create_time,
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _emit_finished(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
pid: int,
|
|
280
|
+
name: str,
|
|
281
|
+
exe: str | None,
|
|
282
|
+
cmdline: tuple[str, ...],
|
|
283
|
+
reason: str,
|
|
284
|
+
first_seen: datetime,
|
|
285
|
+
) -> None:
|
|
286
|
+
now = utc_now()
|
|
287
|
+
self._emit_event(
|
|
288
|
+
Event.create(
|
|
289
|
+
EventKind.PROCESS_FINISHED,
|
|
290
|
+
source=self.name,
|
|
291
|
+
occurred_at=now,
|
|
292
|
+
payload={
|
|
293
|
+
"pid": pid,
|
|
294
|
+
"name": name,
|
|
295
|
+
"exe": exe,
|
|
296
|
+
"cmdline": list(cmdline),
|
|
297
|
+
"command": shlex.join(cmdline),
|
|
298
|
+
"reason": reason,
|
|
299
|
+
"observed_lifetime_s": max(0.0, (now - first_seen).total_seconds()),
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _emit_event(self, event: Event) -> None:
|
|
305
|
+
if self._emit is None:
|
|
306
|
+
return
|
|
307
|
+
try:
|
|
308
|
+
self._emit(event)
|
|
309
|
+
self._events_emitted += 1
|
|
310
|
+
except Exception as exc: # pragma: no cover - defensive queue boundary
|
|
311
|
+
self._error = f"event emission failed: {exc}"
|
|
312
|
+
self._stop.set()
|
|
313
|
+
|
|
314
|
+
def stop(self) -> CollectorStatus:
|
|
315
|
+
self._stop.set()
|
|
316
|
+
if self._thread is not None:
|
|
317
|
+
self._thread.join(timeout=30)
|
|
318
|
+
self._thread = None
|
|
319
|
+
try:
|
|
320
|
+
self._poll()
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
self._error = f"final process poll failed: {exc}"
|
|
323
|
+
|
|
324
|
+
with self._lock:
|
|
325
|
+
survivors = list(self._known.values())
|
|
326
|
+
for info in survivors:
|
|
327
|
+
self._emit_event(
|
|
328
|
+
Event.create(
|
|
329
|
+
EventKind.PROCESS_STILL_RUNNING,
|
|
330
|
+
source=self.name,
|
|
331
|
+
payload={
|
|
332
|
+
"pid": info.pid,
|
|
333
|
+
"ppid": info.ppid,
|
|
334
|
+
"name": info.name,
|
|
335
|
+
"exe": info.exe,
|
|
336
|
+
"cmdline": list(info.cmdline),
|
|
337
|
+
"command": shlex.join(info.cmdline),
|
|
338
|
+
},
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
state = CollectorState.OK
|
|
343
|
+
messages: list[str] = []
|
|
344
|
+
if self._error:
|
|
345
|
+
state = CollectorState.FAILED
|
|
346
|
+
messages.append(self._error)
|
|
347
|
+
elif self._access_denied:
|
|
348
|
+
state = CollectorState.DEGRADED
|
|
349
|
+
messages.append("some processes could not be inspected (access denied)")
|
|
350
|
+
if self._root_vanished:
|
|
351
|
+
messages.append("root process exited before process observation began")
|
|
352
|
+
|
|
353
|
+
return CollectorStatus(
|
|
354
|
+
name=self.name,
|
|
355
|
+
state=state,
|
|
356
|
+
message="; ".join(messages) if messages else None,
|
|
357
|
+
details={
|
|
358
|
+
"root_pid": self._root_pid,
|
|
359
|
+
"observed_processes": self._events_emitted,
|
|
360
|
+
"still_running": len(survivors),
|
|
361
|
+
"access_denied": self._access_denied,
|
|
362
|
+
},
|
|
363
|
+
started_at=self._started_at,
|
|
364
|
+
stopped_at=utc_now(),
|
|
365
|
+
)
|