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
cmdscope/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""CmdScope: local, privacy-first observation of command execution.
|
|
2
|
+
|
|
3
|
+
CmdScope runs a command and records filesystem, process, and network facts that
|
|
4
|
+
are observable during the session. It never captures file contents and never
|
|
5
|
+
sends data anywhere.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
cmdscope/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis helpers."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Compare the observed event sets of two sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from cmdscope.core.events import EventKind, StoredEvent
|
|
9
|
+
from cmdscope.core.session import Session
|
|
10
|
+
from cmdscope.storage.sqlite import Storage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ComparisonItem:
|
|
15
|
+
category: str
|
|
16
|
+
action: str
|
|
17
|
+
label: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ComparisonResult:
|
|
22
|
+
session_a: Session
|
|
23
|
+
session_b: Session
|
|
24
|
+
only_in_a: tuple[ComparisonItem, ...]
|
|
25
|
+
only_in_b: tuple[ComparisonItem, ...]
|
|
26
|
+
common_count: int
|
|
27
|
+
events_a: int
|
|
28
|
+
events_b: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _filesystem_item(event: StoredEvent) -> ComparisonItem:
|
|
32
|
+
path = str(event.payload.get("path", ""))
|
|
33
|
+
suffix = str(event.kind).split(".", 1)[-1]
|
|
34
|
+
return ComparisonItem(category="filesystem", action=suffix, label=path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _process_item(event: StoredEvent) -> ComparisonItem:
|
|
38
|
+
name = str(event.payload.get("name", "unknown"))
|
|
39
|
+
command = str(event.payload.get("command", "")) or name
|
|
40
|
+
action = str(event.kind).split(".", 1)[-1]
|
|
41
|
+
return ComparisonItem(category="processes", action=action, label=command)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _network_item(event: StoredEvent) -> ComparisonItem:
|
|
45
|
+
process = str(event.payload.get("process_name", "unknown"))
|
|
46
|
+
remote = event.payload.get("remote_address")
|
|
47
|
+
local = event.payload.get("local_address")
|
|
48
|
+
protocol = str(event.payload.get("protocol", "TCP"))
|
|
49
|
+
endpoint = str(remote or local or "unknown")
|
|
50
|
+
return ComparisonItem(
|
|
51
|
+
category="network",
|
|
52
|
+
action="connection",
|
|
53
|
+
label=f"{process} -> {endpoint} ({protocol})",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _key(event: StoredEvent) -> tuple[str, str] | None:
|
|
58
|
+
if event.kind in {
|
|
59
|
+
EventKind.FILESYSTEM_CREATED,
|
|
60
|
+
EventKind.FILESYSTEM_MODIFIED,
|
|
61
|
+
EventKind.FILESYSTEM_DELETED,
|
|
62
|
+
}:
|
|
63
|
+
path = os.path.normcase(str(event.payload.get("path", "")))
|
|
64
|
+
return (f"filesystem|{event.kind}", path)
|
|
65
|
+
if event.kind in {EventKind.PROCESS_STARTED, EventKind.PROCESS_STILL_RUNNING}:
|
|
66
|
+
name = str(event.payload.get("name", "unknown"))
|
|
67
|
+
command = str(event.payload.get("command", ""))
|
|
68
|
+
return (f"processes|{event.kind}", os.path.normcase(f"{name}|{command}"))
|
|
69
|
+
if event.kind == EventKind.NETWORK_CONNECTION:
|
|
70
|
+
process = str(event.payload.get("process_name", "unknown"))
|
|
71
|
+
remote = str(event.payload.get("remote_address") or "")
|
|
72
|
+
local = str(event.payload.get("local_address") or "")
|
|
73
|
+
protocol = str(event.payload.get("protocol", ""))
|
|
74
|
+
return ("network|connection", os.path.normcase(f"{process}|{protocol}|{remote or local}"))
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _collect(events: list[StoredEvent]) -> dict[tuple[str, str], ComparisonItem]:
|
|
79
|
+
items: dict[tuple[str, str], ComparisonItem] = {}
|
|
80
|
+
for event in events:
|
|
81
|
+
key = _key(event)
|
|
82
|
+
if key is None or key in items:
|
|
83
|
+
continue
|
|
84
|
+
if key[0].startswith("filesystem"):
|
|
85
|
+
items[key] = _filesystem_item(event)
|
|
86
|
+
elif key[0].startswith("processes"):
|
|
87
|
+
items[key] = _process_item(event)
|
|
88
|
+
else:
|
|
89
|
+
items[key] = _network_item(event)
|
|
90
|
+
return items
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sorted(items: list[ComparisonItem]) -> tuple[ComparisonItem, ...]:
|
|
94
|
+
return tuple(
|
|
95
|
+
sorted(items, key=lambda item: (item.category, item.action, item.label.casefold()))
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compare_events(
|
|
100
|
+
session_a: Session,
|
|
101
|
+
events_a: list[StoredEvent],
|
|
102
|
+
session_b: Session,
|
|
103
|
+
events_b: list[StoredEvent],
|
|
104
|
+
) -> ComparisonResult:
|
|
105
|
+
"""Return events unique to each session and the number shared by both."""
|
|
106
|
+
|
|
107
|
+
items_a = _collect(events_a)
|
|
108
|
+
items_b = _collect(events_b)
|
|
109
|
+
keys_a = set(items_a)
|
|
110
|
+
keys_b = set(items_b)
|
|
111
|
+
only_a = _sorted([items_a[key] for key in keys_a - keys_b])
|
|
112
|
+
only_b = _sorted([items_b[key] for key in keys_b - keys_a])
|
|
113
|
+
return ComparisonResult(
|
|
114
|
+
session_a=session_a,
|
|
115
|
+
session_b=session_b,
|
|
116
|
+
only_in_a=only_a,
|
|
117
|
+
only_in_b=only_b,
|
|
118
|
+
common_count=len(keys_a & keys_b),
|
|
119
|
+
events_a=len(events_a),
|
|
120
|
+
events_b=len(events_b),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def compare_sessions(storage: Storage, reference_a: str, reference_b: str) -> ComparisonResult:
|
|
125
|
+
"""Load both sessions and compare their persisted events."""
|
|
126
|
+
|
|
127
|
+
session_a = storage.require_session(reference_a)
|
|
128
|
+
session_b = storage.require_session(reference_b)
|
|
129
|
+
return compare_events(
|
|
130
|
+
session_a,
|
|
131
|
+
storage.get_events(session_a.id),
|
|
132
|
+
session_b,
|
|
133
|
+
storage.get_events(session_b.id),
|
|
134
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Summary calculations derived only from stored events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from cmdscope.core.events import CollectorState, EventKind, StoredEvent
|
|
9
|
+
from cmdscope.core.session import Session, SessionSummary
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def summarize(session: Session, events: Sequence[StoredEvent]) -> SessionSummary:
|
|
13
|
+
"""Count observed events and surface collector degradation as warnings."""
|
|
14
|
+
|
|
15
|
+
counts = Counter(event.kind for event in events)
|
|
16
|
+
warnings: list[str] = []
|
|
17
|
+
for collector in session.collectors:
|
|
18
|
+
if collector.state in (CollectorState.DEGRADED, CollectorState.FAILED):
|
|
19
|
+
message = collector.message or f"collector state is {collector.state}"
|
|
20
|
+
warnings.append(f"{collector.name}: {message}")
|
|
21
|
+
if session.error:
|
|
22
|
+
warnings.append(session.error)
|
|
23
|
+
if not events:
|
|
24
|
+
warnings.append("no events were recorded for this session")
|
|
25
|
+
|
|
26
|
+
return SessionSummary(
|
|
27
|
+
session=session,
|
|
28
|
+
events_total=len(events),
|
|
29
|
+
filesystem_created=counts[EventKind.FILESYSTEM_CREATED],
|
|
30
|
+
filesystem_modified=counts[EventKind.FILESYSTEM_MODIFIED],
|
|
31
|
+
filesystem_deleted=counts[EventKind.FILESYSTEM_DELETED],
|
|
32
|
+
processes_started=counts[EventKind.PROCESS_STARTED],
|
|
33
|
+
processes_finished=counts[EventKind.PROCESS_FINISHED],
|
|
34
|
+
processes_still_running=counts[EventKind.PROCESS_STILL_RUNNING],
|
|
35
|
+
network_connections=counts[EventKind.NETWORK_CONNECTION],
|
|
36
|
+
warnings=tuple(warnings),
|
|
37
|
+
)
|