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,370 @@
|
|
|
1
|
+
"""Filesystem collector: snapshot diff plus native events with polling fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cmdscope.core.collector import CollectorContext
|
|
15
|
+
from cmdscope.core.events import (
|
|
16
|
+
CollectorState,
|
|
17
|
+
CollectorStatus,
|
|
18
|
+
Event,
|
|
19
|
+
EventKind,
|
|
20
|
+
utc_now,
|
|
21
|
+
)
|
|
22
|
+
from cmdscope.core.ignore import IgnoreRules
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class FileState:
|
|
27
|
+
size: int
|
|
28
|
+
mtime_ns: int
|
|
29
|
+
is_dir: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ScanResult:
|
|
34
|
+
states: dict[Path, FileState]
|
|
35
|
+
truncated: bool
|
|
36
|
+
error_count: int
|
|
37
|
+
error_sample: str | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _state(path: Path) -> FileState:
|
|
41
|
+
info = path.lstat()
|
|
42
|
+
return FileState(
|
|
43
|
+
size=info.st_size,
|
|
44
|
+
mtime_ns=info.st_mtime_ns,
|
|
45
|
+
is_dir=stat.S_ISDIR(info.st_mode),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan_tree(root: Path, ignore: IgnoreRules, max_files: int) -> ScanResult:
|
|
50
|
+
"""Walk *root* without following symlinks and return metadata per entry."""
|
|
51
|
+
|
|
52
|
+
states: dict[Path, FileState] = {}
|
|
53
|
+
stack = [root]
|
|
54
|
+
error_count = 0
|
|
55
|
+
error_sample: str | None = None
|
|
56
|
+
while stack:
|
|
57
|
+
current = stack.pop()
|
|
58
|
+
try:
|
|
59
|
+
with os.scandir(current) as entries:
|
|
60
|
+
for entry in entries:
|
|
61
|
+
path = Path(entry.path)
|
|
62
|
+
if ignore.matches(path, root):
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
info = entry.stat(follow_symlinks=False)
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
error_count += 1
|
|
68
|
+
error_sample = error_sample or f"{entry.path}: {exc}"
|
|
69
|
+
continue
|
|
70
|
+
is_dir = stat.S_ISDIR(info.st_mode)
|
|
71
|
+
states[path] = FileState(info.st_size, info.st_mtime_ns, is_dir)
|
|
72
|
+
if len(states) >= max_files:
|
|
73
|
+
return ScanResult(states, True, error_count, error_sample)
|
|
74
|
+
if is_dir:
|
|
75
|
+
stack.append(path)
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
error_count += 1
|
|
78
|
+
error_sample = error_sample or f"{current}: {exc}"
|
|
79
|
+
return ScanResult(states, False, error_count, error_sample)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FilesystemCollector:
|
|
83
|
+
"""Observe metadata changes under explicit roots.
|
|
84
|
+
|
|
85
|
+
The baseline snapshot is taken before the monitored command starts. Native
|
|
86
|
+
events provide low latency; a final snapshot reconciliation catches changes
|
|
87
|
+
that the OS event stream missed. If the snapshot limit is reached, CmdScope
|
|
88
|
+
refuses to infer deletions from the partial snapshot and reports the
|
|
89
|
+
collector as degraded.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
name = "filesystem"
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
max_files: int,
|
|
98
|
+
poll_interval: float,
|
|
99
|
+
use_native_events: bool = True,
|
|
100
|
+
clock: Callable[[], datetime] = utc_now,
|
|
101
|
+
) -> None:
|
|
102
|
+
self._max_files = max_files
|
|
103
|
+
self._poll_interval = poll_interval
|
|
104
|
+
self._use_native_events = use_native_events
|
|
105
|
+
self._clock = clock
|
|
106
|
+
self._lock = threading.RLock()
|
|
107
|
+
self._stop = threading.Event()
|
|
108
|
+
self._states: dict[Path, FileState] = {}
|
|
109
|
+
self._roots: tuple[Path, ...] = ()
|
|
110
|
+
self._ignore = IgnoreRules()
|
|
111
|
+
self._emit: Callable[[Event], None] | None = None
|
|
112
|
+
self._events_emitted = 0
|
|
113
|
+
self._baseline_truncated = False
|
|
114
|
+
self._scan_errors = 0
|
|
115
|
+
self._scan_error_sample: str | None = None
|
|
116
|
+
self._observer: Any = None
|
|
117
|
+
self._thread: threading.Thread | None = None
|
|
118
|
+
self._backend = "none"
|
|
119
|
+
self._observer_error: str | None = None
|
|
120
|
+
self._fatal_error: str | None = None
|
|
121
|
+
self._started_at: datetime | None = None
|
|
122
|
+
|
|
123
|
+
def start(self, context: CollectorContext) -> None:
|
|
124
|
+
self._roots = context.watch_roots
|
|
125
|
+
self._ignore = context.ignore
|
|
126
|
+
self._emit = context.emit
|
|
127
|
+
self._started_at = utc_now()
|
|
128
|
+
|
|
129
|
+
baseline: dict[Path, FileState] = {}
|
|
130
|
+
for root in self._roots:
|
|
131
|
+
scan = scan_tree(root, self._ignore, self._max_files)
|
|
132
|
+
baseline.update(scan.states)
|
|
133
|
+
self._baseline_truncated = self._baseline_truncated or scan.truncated
|
|
134
|
+
self._accumulate_scan_errors(scan)
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._states = baseline
|
|
137
|
+
|
|
138
|
+
if self._use_native_events and self._start_observer():
|
|
139
|
+
self._backend = "native"
|
|
140
|
+
else:
|
|
141
|
+
self._backend = "polling"
|
|
142
|
+
self._thread = threading.Thread(
|
|
143
|
+
target=self._poll_loop, name="cmdscope-fs-poller", daemon=True
|
|
144
|
+
)
|
|
145
|
+
self._thread.start()
|
|
146
|
+
|
|
147
|
+
def _accumulate_scan_errors(self, scan: ScanResult) -> None:
|
|
148
|
+
self._scan_errors += scan.error_count
|
|
149
|
+
if self._scan_error_sample is None:
|
|
150
|
+
self._scan_error_sample = scan.error_sample
|
|
151
|
+
|
|
152
|
+
def _start_observer(self) -> bool:
|
|
153
|
+
try:
|
|
154
|
+
from watchdog.events import FileSystemEventHandler
|
|
155
|
+
from watchdog.observers import Observer
|
|
156
|
+
except Exception as exc: # pragma: no cover - required dependency is installed
|
|
157
|
+
self._observer_error = f"watchdog unavailable: {exc}"
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
collection = self
|
|
161
|
+
|
|
162
|
+
class Handler(FileSystemEventHandler):
|
|
163
|
+
def on_any_event(self, event: Any) -> None:
|
|
164
|
+
collection._handle_watchdog_event(event)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
observer = Observer()
|
|
168
|
+
handler = Handler()
|
|
169
|
+
for root in self._roots:
|
|
170
|
+
observer.schedule(handler, str(root), recursive=True)
|
|
171
|
+
observer.start()
|
|
172
|
+
self._observer = observer
|
|
173
|
+
return True
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
self._observer_error = f"native filesystem observer unavailable: {exc}"
|
|
176
|
+
self._observer = None
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def _handle_watchdog_event(self, event: Any) -> None:
|
|
180
|
+
try:
|
|
181
|
+
event_type = str(getattr(event, "event_type", ""))
|
|
182
|
+
is_directory = bool(getattr(event, "is_directory", False))
|
|
183
|
+
if event_type == "modified" and is_directory:
|
|
184
|
+
return
|
|
185
|
+
if event_type == "moved":
|
|
186
|
+
source = getattr(event, "src_path", None)
|
|
187
|
+
destination = getattr(event, "dest_path", None)
|
|
188
|
+
paths = [source, destination]
|
|
189
|
+
elif event_type in {"created", "modified", "deleted"}:
|
|
190
|
+
paths = [getattr(event, "src_path", None)]
|
|
191
|
+
else:
|
|
192
|
+
return
|
|
193
|
+
for value in paths:
|
|
194
|
+
if value:
|
|
195
|
+
self._observe_path(Path(value))
|
|
196
|
+
except Exception as exc: # pragma: no cover - watchdog callbacks must keep running
|
|
197
|
+
self._fatal_error = f"filesystem event handling failed: {exc}"
|
|
198
|
+
|
|
199
|
+
def _observe_path(self, path: Path) -> None:
|
|
200
|
+
root = self._root_for(path)
|
|
201
|
+
if root is None or self._ignore.matches(path, root):
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
state = _state(path)
|
|
205
|
+
except FileNotFoundError:
|
|
206
|
+
self._observe_deleted(path, root)
|
|
207
|
+
return
|
|
208
|
+
except OSError:
|
|
209
|
+
self._scan_errors += 1
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
event: Event | None = None
|
|
213
|
+
with self._lock:
|
|
214
|
+
previous = self._states.get(path)
|
|
215
|
+
if previous is None:
|
|
216
|
+
self._states[path] = state
|
|
217
|
+
event = self._event(EventKind.FILESYSTEM_CREATED, path, state, root)
|
|
218
|
+
elif previous != state:
|
|
219
|
+
self._states[path] = state
|
|
220
|
+
if not (previous.is_dir and state.is_dir):
|
|
221
|
+
event = self._event(EventKind.FILESYSTEM_MODIFIED, path, state, root)
|
|
222
|
+
if event is not None:
|
|
223
|
+
self._emit_event(event)
|
|
224
|
+
|
|
225
|
+
def _observe_deleted(self, path: Path, root: Path) -> None:
|
|
226
|
+
with self._lock:
|
|
227
|
+
previous = self._states.pop(path, None)
|
|
228
|
+
if previous is not None:
|
|
229
|
+
self._emit_event(self._event(EventKind.FILESYSTEM_DELETED, path, previous, root))
|
|
230
|
+
|
|
231
|
+
def _root_for(self, path: Path) -> Path | None:
|
|
232
|
+
matches = [root for root in self._roots if path == root or path.is_relative_to(root)]
|
|
233
|
+
return max(matches, key=lambda item: len(item.parts)) if matches else None
|
|
234
|
+
|
|
235
|
+
def _event(self, kind: EventKind, path: Path, state: FileState, root: Path) -> Event:
|
|
236
|
+
return Event.create(
|
|
237
|
+
kind,
|
|
238
|
+
source=self.name,
|
|
239
|
+
occurred_at=self._clock(),
|
|
240
|
+
payload={
|
|
241
|
+
"path": str(path),
|
|
242
|
+
"root": str(root),
|
|
243
|
+
"is_dir": state.is_dir,
|
|
244
|
+
"size": state.size,
|
|
245
|
+
"mtime_ns": state.mtime_ns,
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _emit_event(self, event: Event) -> None:
|
|
250
|
+
if self._emit is None:
|
|
251
|
+
return
|
|
252
|
+
try:
|
|
253
|
+
self._emit(event)
|
|
254
|
+
self._events_emitted += 1
|
|
255
|
+
except Exception as exc: # pragma: no cover - defensive boundary around the queue
|
|
256
|
+
self._fatal_error = f"event emission failed: {exc}"
|
|
257
|
+
self._stop.set()
|
|
258
|
+
|
|
259
|
+
def _poll_loop(self) -> None:
|
|
260
|
+
while not self._stop.is_set():
|
|
261
|
+
try:
|
|
262
|
+
self._reconcile()
|
|
263
|
+
except Exception as exc: # pragma: no cover - defensive thread boundary
|
|
264
|
+
self._fatal_error = f"filesystem polling failed: {exc}"
|
|
265
|
+
return
|
|
266
|
+
if self._stop.wait(self._poll_interval):
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
def _reconcile(self) -> None:
|
|
270
|
+
if self._baseline_truncated:
|
|
271
|
+
return
|
|
272
|
+
merged: dict[Path, FileState] = {}
|
|
273
|
+
truncated = False
|
|
274
|
+
for watch_root in self._roots:
|
|
275
|
+
scan = scan_tree(watch_root, self._ignore, self._max_files)
|
|
276
|
+
merged.update(scan.states)
|
|
277
|
+
truncated = truncated or scan.truncated
|
|
278
|
+
self._accumulate_scan_errors(scan)
|
|
279
|
+
if truncated:
|
|
280
|
+
self._baseline_truncated = True
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
added: list[tuple[Path, FileState]] = []
|
|
284
|
+
removed: list[tuple[Path, FileState]] = []
|
|
285
|
+
changed: list[tuple[Path, FileState]] = []
|
|
286
|
+
with self._lock:
|
|
287
|
+
previous_states = self._states
|
|
288
|
+
for path, state in merged.items():
|
|
289
|
+
previous = previous_states.get(path)
|
|
290
|
+
if previous is None:
|
|
291
|
+
added.append((path, state))
|
|
292
|
+
elif previous != state and not (previous.is_dir and state.is_dir):
|
|
293
|
+
changed.append((path, state))
|
|
294
|
+
for path, state in previous_states.items():
|
|
295
|
+
if path not in merged:
|
|
296
|
+
removed.append((path, state))
|
|
297
|
+
self._states = merged
|
|
298
|
+
|
|
299
|
+
for path, state in sorted(added, key=lambda item: str(item[0])):
|
|
300
|
+
root = self._root_for(path)
|
|
301
|
+
if root is not None:
|
|
302
|
+
self._emit_event(self._event(EventKind.FILESYSTEM_CREATED, path, state, root))
|
|
303
|
+
for path, state in sorted(removed, key=lambda item: str(item[0])):
|
|
304
|
+
root = self._root_for(path)
|
|
305
|
+
if root is not None:
|
|
306
|
+
self._emit_event(self._event(EventKind.FILESYSTEM_DELETED, path, state, root))
|
|
307
|
+
for path, state in sorted(changed, key=lambda item: str(item[0])):
|
|
308
|
+
root = self._root_for(path)
|
|
309
|
+
if root is not None:
|
|
310
|
+
self._emit_event(self._event(EventKind.FILESYSTEM_MODIFIED, path, state, root))
|
|
311
|
+
|
|
312
|
+
def stop(self) -> CollectorStatus:
|
|
313
|
+
self._stop.set()
|
|
314
|
+
if self._observer is not None:
|
|
315
|
+
try:
|
|
316
|
+
self._observer.stop()
|
|
317
|
+
self._observer.join(timeout=10)
|
|
318
|
+
except Exception as exc: # pragma: no cover - observer shutdown boundary
|
|
319
|
+
self._fatal_error = f"native observer shutdown failed: {exc}"
|
|
320
|
+
self._observer = None
|
|
321
|
+
if self._thread is not None:
|
|
322
|
+
self._thread.join(timeout=30)
|
|
323
|
+
self._thread = None
|
|
324
|
+
|
|
325
|
+
if not self._baseline_truncated:
|
|
326
|
+
try:
|
|
327
|
+
self._reconcile()
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
self._fatal_error = f"final filesystem reconciliation failed: {exc}"
|
|
330
|
+
|
|
331
|
+
state = CollectorState.OK
|
|
332
|
+
messages: list[str] = []
|
|
333
|
+
if self._fatal_error:
|
|
334
|
+
state = CollectorState.FAILED
|
|
335
|
+
messages.append(self._fatal_error)
|
|
336
|
+
elif self._baseline_truncated:
|
|
337
|
+
state = CollectorState.DEGRADED
|
|
338
|
+
messages.append(
|
|
339
|
+
f"snapshot limit of {self._max_files} entries reached; changes are partial"
|
|
340
|
+
)
|
|
341
|
+
elif self._scan_errors:
|
|
342
|
+
state = CollectorState.DEGRADED
|
|
343
|
+
messages.append(
|
|
344
|
+
f"{self._scan_errors} paths could not be inspected"
|
|
345
|
+
+ (f" (first: {self._scan_error_sample})" if self._scan_error_sample else "")
|
|
346
|
+
)
|
|
347
|
+
if self._observer_error:
|
|
348
|
+
if state == CollectorState.OK:
|
|
349
|
+
state = CollectorState.DEGRADED
|
|
350
|
+
messages.append(self._observer_error)
|
|
351
|
+
|
|
352
|
+
details: dict[str, Any] = {
|
|
353
|
+
"backend": self._backend,
|
|
354
|
+
"watch_roots": [str(root) for root in self._roots],
|
|
355
|
+
"max_files": self._max_files,
|
|
356
|
+
"baseline_truncated": self._baseline_truncated,
|
|
357
|
+
"scan_errors": self._scan_errors,
|
|
358
|
+
"events_emitted": self._events_emitted,
|
|
359
|
+
}
|
|
360
|
+
if self._observer_error:
|
|
361
|
+
details["observer_error"] = self._observer_error
|
|
362
|
+
|
|
363
|
+
return CollectorStatus(
|
|
364
|
+
name=self.name,
|
|
365
|
+
state=state,
|
|
366
|
+
message="; ".join(messages) if messages else None,
|
|
367
|
+
details=details,
|
|
368
|
+
started_at=self._started_at,
|
|
369
|
+
stopped_at=utc_now(),
|
|
370
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Network endpoint collector based on psutil polling and the process registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
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
|
+
|
|
23
|
+
|
|
24
|
+
def _endpoint(value: Any) -> str | None:
|
|
25
|
+
if not value:
|
|
26
|
+
return None
|
|
27
|
+
try:
|
|
28
|
+
host, port = value[0], value[1]
|
|
29
|
+
except (TypeError, IndexError):
|
|
30
|
+
return str(value)
|
|
31
|
+
host_text = str(host)
|
|
32
|
+
if ":" in host_text and not host_text.startswith("["):
|
|
33
|
+
host_text = f"[{host_text}]"
|
|
34
|
+
return f"{host_text}:{port}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _family_name(family: Any) -> str:
|
|
38
|
+
if family == socket.AF_INET6:
|
|
39
|
+
return "IPv6"
|
|
40
|
+
if family == socket.AF_INET:
|
|
41
|
+
return "IPv4"
|
|
42
|
+
return str(family)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _protocol_name(protocol: Any) -> str:
|
|
46
|
+
if protocol == socket.SOCK_STREAM:
|
|
47
|
+
return "TCP"
|
|
48
|
+
if protocol == socket.SOCK_DGRAM:
|
|
49
|
+
return "UDP"
|
|
50
|
+
return str(protocol)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NetworkCollector:
|
|
54
|
+
"""Attribute observed inet sockets to processes already in the session.
|
|
55
|
+
|
|
56
|
+
Sockets are sampled. Connections shorter than one poll interval can be
|
|
57
|
+
missed. No packet contents or DNS lookups are used.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name = "network"
|
|
61
|
+
|
|
62
|
+
def __init__(self, *, poll_interval: float) -> None:
|
|
63
|
+
self._poll_interval = poll_interval
|
|
64
|
+
self._stop = threading.Event()
|
|
65
|
+
self._registry = ProcessRegistry()
|
|
66
|
+
self._emit: Callable[[Event], None] | None = None
|
|
67
|
+
self._thread: threading.Thread | None = None
|
|
68
|
+
self._seen: set[tuple[Any, ...]] = set()
|
|
69
|
+
self._access_denied = False
|
|
70
|
+
self._unattributed: set[tuple[Any, ...]] = set()
|
|
71
|
+
self._error: str | None = None
|
|
72
|
+
self._started_at: datetime | None = None
|
|
73
|
+
self._connections = 0
|
|
74
|
+
|
|
75
|
+
def start(self, context: CollectorContext) -> None:
|
|
76
|
+
self._registry = context.registry
|
|
77
|
+
self._emit = context.emit
|
|
78
|
+
self._started_at = utc_now()
|
|
79
|
+
self._thread = threading.Thread(
|
|
80
|
+
target=self._poll_loop, name="cmdscope-network-poller", daemon=True
|
|
81
|
+
)
|
|
82
|
+
self._thread.start()
|
|
83
|
+
|
|
84
|
+
def _poll_loop(self) -> None:
|
|
85
|
+
while not self._stop.is_set():
|
|
86
|
+
try:
|
|
87
|
+
self.poll_once()
|
|
88
|
+
except Exception as exc: # pragma: no cover - defensive thread boundary
|
|
89
|
+
self._error = f"network polling failed: {exc}"
|
|
90
|
+
return
|
|
91
|
+
if self._stop.wait(self._poll_interval):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
def poll_once(self) -> None:
|
|
95
|
+
"""Sample sockets once. Public for deterministic diagnostics/tests."""
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
connections = psutil.net_connections(kind="inet")
|
|
99
|
+
except (psutil.AccessDenied, PermissionError) as exc:
|
|
100
|
+
self._access_denied = True
|
|
101
|
+
self._error = f"network inspection access denied: {exc}"
|
|
102
|
+
return
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
self._error = f"network inspection failed: {exc}"
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
registry = self._registry.snapshot()
|
|
108
|
+
for connection in connections:
|
|
109
|
+
pid = getattr(connection, "pid", None)
|
|
110
|
+
local = getattr(connection, "laddr", None)
|
|
111
|
+
remote = getattr(connection, "raddr", None)
|
|
112
|
+
family = getattr(connection, "family", None)
|
|
113
|
+
protocol = getattr(connection, "type", None)
|
|
114
|
+
if pid is None:
|
|
115
|
+
self._unattributed.add(
|
|
116
|
+
(
|
|
117
|
+
family,
|
|
118
|
+
protocol,
|
|
119
|
+
tuple(local) if local else None,
|
|
120
|
+
tuple(remote) if remote else None,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
if pid not in registry:
|
|
125
|
+
continue
|
|
126
|
+
key = (
|
|
127
|
+
pid,
|
|
128
|
+
family,
|
|
129
|
+
protocol,
|
|
130
|
+
tuple(local) if local else None,
|
|
131
|
+
tuple(remote) if remote else None,
|
|
132
|
+
)
|
|
133
|
+
if key in self._seen:
|
|
134
|
+
continue
|
|
135
|
+
self._seen.add(key)
|
|
136
|
+
payload = {
|
|
137
|
+
"pid": pid,
|
|
138
|
+
"process_name": registry[pid],
|
|
139
|
+
"family": _family_name(family),
|
|
140
|
+
"protocol": _protocol_name(protocol),
|
|
141
|
+
"local_address": _endpoint(local),
|
|
142
|
+
"remote_address": _endpoint(remote),
|
|
143
|
+
"status": str(getattr(connection, "status", "") or "UNKNOWN"),
|
|
144
|
+
}
|
|
145
|
+
self._emit_event(
|
|
146
|
+
Event.create(EventKind.NETWORK_CONNECTION, source=self.name, payload=payload)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _emit_event(self, event: Event) -> None:
|
|
150
|
+
if self._emit is None:
|
|
151
|
+
return
|
|
152
|
+
try:
|
|
153
|
+
self._emit(event)
|
|
154
|
+
self._connections += 1
|
|
155
|
+
except Exception as exc: # pragma: no cover - defensive queue boundary
|
|
156
|
+
self._error = f"event emission failed: {exc}"
|
|
157
|
+
self._stop.set()
|
|
158
|
+
|
|
159
|
+
def stop(self) -> CollectorStatus:
|
|
160
|
+
self._stop.set()
|
|
161
|
+
if self._thread is not None:
|
|
162
|
+
self._thread.join(timeout=30)
|
|
163
|
+
self._thread = None
|
|
164
|
+
try:
|
|
165
|
+
self.poll_once()
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
self._error = f"final network poll failed: {exc}"
|
|
168
|
+
|
|
169
|
+
state = CollectorState.OK
|
|
170
|
+
messages: list[str] = []
|
|
171
|
+
if self._error and self._access_denied:
|
|
172
|
+
state = CollectorState.DEGRADED
|
|
173
|
+
messages.append(self._error)
|
|
174
|
+
elif self._error:
|
|
175
|
+
state = CollectorState.FAILED
|
|
176
|
+
messages.append(self._error)
|
|
177
|
+
elif not self._connections and self._unattributed:
|
|
178
|
+
state = CollectorState.DEGRADED
|
|
179
|
+
messages.append(
|
|
180
|
+
"socket owners are not visible on this platform; "
|
|
181
|
+
"network endpoints could not be attributed"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return CollectorStatus(
|
|
185
|
+
name=self.name,
|
|
186
|
+
state=state,
|
|
187
|
+
message="; ".join(messages) if messages else None,
|
|
188
|
+
details={
|
|
189
|
+
"connections_observed": self._connections,
|
|
190
|
+
"unattributed_sockets": len(self._unattributed),
|
|
191
|
+
"access_denied": self._access_denied,
|
|
192
|
+
"poll_interval": self._poll_interval,
|
|
193
|
+
},
|
|
194
|
+
started_at=self._started_at,
|
|
195
|
+
stopped_at=utc_now(),
|
|
196
|
+
)
|