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/privacy.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Sanitization and environment-diff helpers.
|
|
2
|
+
|
|
3
|
+
CmdScope stores metadata, never file contents. Command arguments and environment
|
|
4
|
+
values are still potentially sensitive, so every value passes through this module
|
|
5
|
+
before it reaches SQLite, a report, or a log.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from cmdscope.core.events import JSONValue
|
|
17
|
+
|
|
18
|
+
REDACTED = "[REDACTED]"
|
|
19
|
+
|
|
20
|
+
# Normalized names are compared without punctuation, so AWS_SECRET_ACCESS_KEY,
|
|
21
|
+
# --api-key and clientSecret all match.
|
|
22
|
+
SENSITIVE_MARKERS: tuple[str, ...] = (
|
|
23
|
+
"password",
|
|
24
|
+
"passwd",
|
|
25
|
+
"secret",
|
|
26
|
+
"token",
|
|
27
|
+
"credential",
|
|
28
|
+
"apikey",
|
|
29
|
+
"accesskey",
|
|
30
|
+
"privatekey",
|
|
31
|
+
"clientsecret",
|
|
32
|
+
"authorization",
|
|
33
|
+
"cookie",
|
|
34
|
+
"session",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_SENSITIVE_FLAG_RE = re.compile(r"^[-/]{1,2}[A-Za-z0-9_.-]+$")
|
|
38
|
+
_URL_CREDENTIALS_RE = re.compile(r"(://[^/\s:@]+:)([^/\s@]+)(@)")
|
|
39
|
+
_AUTHORIZATION_RE = re.compile(r"(?i)(authorization\s*:\s*(?:bearer|basic|token)?\s*)\S+")
|
|
40
|
+
_BEARER_RE = re.compile(r"(?i)\b(bearer)\s+[A-Za-z0-9._~+/-]+=*")
|
|
41
|
+
_SENSITIVE_QUERY_RE = re.compile(
|
|
42
|
+
r"(?i)([?&](?:token|api_key|apikey|access_token|secret|password)=)[^&\s]+"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_sensitive_name(name: str) -> bool:
|
|
47
|
+
"""Return whether a flag or environment-variable name looks sensitive."""
|
|
48
|
+
|
|
49
|
+
normalized = re.sub(r"[^a-z0-9]", "", name.lower())
|
|
50
|
+
return any(marker in normalized for marker in SENSITIVE_MARKERS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sanitize_env_value(name: str, value: str) -> str:
|
|
54
|
+
"""Redact a value when its variable name is sensitive."""
|
|
55
|
+
|
|
56
|
+
return REDACTED if is_sensitive_name(name) else value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sanitize_environment(environment: Mapping[str, str]) -> dict[str, str]:
|
|
60
|
+
"""Return a copy with sensitive values redacted."""
|
|
61
|
+
|
|
62
|
+
return {key: sanitize_env_value(key, value) for key, value in environment.items()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def environment_diff(
|
|
66
|
+
parent: Mapping[str, str],
|
|
67
|
+
child: Mapping[str, str],
|
|
68
|
+
) -> dict[str, JSONValue]:
|
|
69
|
+
"""Describe added, removed, and changed variables with sanitized values.
|
|
70
|
+
|
|
71
|
+
Runtime mutation of an environment block is not observable without native
|
|
72
|
+
tracing. This diff only compares the environment CmdScope was asked to use
|
|
73
|
+
for the child with the environment of the CmdScope process.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
added: dict[str, JSONValue] = {
|
|
77
|
+
key: sanitize_env_value(key, child[key]) for key in sorted(child.keys() - parent.keys())
|
|
78
|
+
}
|
|
79
|
+
removed: list[JSONValue] = [key for key in sorted(parent.keys() - child.keys())]
|
|
80
|
+
changed: dict[str, JSONValue] = {}
|
|
81
|
+
for key in sorted(parent.keys() & child.keys()):
|
|
82
|
+
if parent[key] == child[key]:
|
|
83
|
+
continue
|
|
84
|
+
changed[key] = {
|
|
85
|
+
"before": sanitize_env_value(key, parent[key]),
|
|
86
|
+
"after": sanitize_env_value(key, child[key]),
|
|
87
|
+
}
|
|
88
|
+
return {"added": added, "removed": removed, "changed": changed}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _sanitize_embedded(value: str) -> str:
|
|
92
|
+
"""Redact credentials embedded inside an argument (URLs, headers, queries)."""
|
|
93
|
+
|
|
94
|
+
sanitized = _URL_CREDENTIALS_RE.sub(rf"\1{REDACTED}\3", value)
|
|
95
|
+
sanitized = _AUTHORIZATION_RE.sub(rf"\1{REDACTED}", sanitized)
|
|
96
|
+
sanitized = _BEARER_RE.sub(rf"\1 {REDACTED}", sanitized)
|
|
97
|
+
return _SENSITIVE_QUERY_RE.sub(rf"\1{REDACTED}", sanitized)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sanitize_command(argv: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
101
|
+
"""Redact values of sensitive flags, assignments, URLs, and headers."""
|
|
102
|
+
|
|
103
|
+
sanitized: list[str] = []
|
|
104
|
+
redact_next = False
|
|
105
|
+
for argument in argv:
|
|
106
|
+
if redact_next:
|
|
107
|
+
sanitized.append(REDACTED)
|
|
108
|
+
redact_next = False
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
handled = False
|
|
112
|
+
if argument.startswith(("-", "/")):
|
|
113
|
+
for separator in ("=", ":"):
|
|
114
|
+
if separator not in argument:
|
|
115
|
+
continue
|
|
116
|
+
flag, _, _value = argument.partition(separator)
|
|
117
|
+
if is_sensitive_name(flag.lstrip("-/")):
|
|
118
|
+
sanitized.append(f"{flag}{separator}{REDACTED}")
|
|
119
|
+
handled = True
|
|
120
|
+
break
|
|
121
|
+
if handled:
|
|
122
|
+
continue
|
|
123
|
+
if _SENSITIVE_FLAG_RE.match(argument) and is_sensitive_name(argument.lstrip("-/")):
|
|
124
|
+
sanitized.append(argument)
|
|
125
|
+
redact_next = True
|
|
126
|
+
continue
|
|
127
|
+
elif "=" in argument:
|
|
128
|
+
name, _, _value = argument.partition("=")
|
|
129
|
+
if is_sensitive_name(name):
|
|
130
|
+
sanitized.append(f"{name}={REDACTED}")
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
sanitized.append(_sanitize_embedded(argument))
|
|
134
|
+
return tuple(sanitized)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def format_command(argv: tuple[str, ...] | list[str]) -> str:
|
|
138
|
+
"""Render a sanitized command for display and storage."""
|
|
139
|
+
|
|
140
|
+
sanitized = sanitize_command(argv)
|
|
141
|
+
if os.name == "nt":
|
|
142
|
+
return subprocess.list2cmdline(list(sanitized))
|
|
143
|
+
return shlex.join(sanitized)
|
cmdscope/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Report renderers."""
|
cmdscope/reports/html.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Self-contained HTML reports with no external assets or scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_CSS = """
|
|
10
|
+
:root { color-scheme: light dark; --bg:#f7f8fa; --panel:#fff; --text:#1c2430;
|
|
11
|
+
--muted:#5b6878; --line:#dfe4ea; --accent:#0b7285; --ok:#2b8a3e; --warn:#b36b00;
|
|
12
|
+
--fail:#c92a2a; --code:#f1f3f5; }
|
|
13
|
+
@media (prefers-color-scheme: dark) {
|
|
14
|
+
:root { --bg:#101418; --panel:#171d24; --text:#e7edf3; --muted:#9aa8b6;
|
|
15
|
+
--line:#2b3540; --accent:#4dabf7; --ok:#69db7c; --warn:#ffc078; --fail:#ff8787;
|
|
16
|
+
--code:#0d1116; }
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body { margin:0; padding:2rem; background:var(--bg); color:var(--text);
|
|
20
|
+
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif; }
|
|
21
|
+
main { max-width:1100px; margin:0 auto; }
|
|
22
|
+
h1 { margin:0 0 .2rem; font-size:1.7rem; letter-spacing:-.02em; }
|
|
23
|
+
h2 { margin:2rem 0 .7rem; font-size:1.05rem; text-transform:uppercase;
|
|
24
|
+
letter-spacing:.08em; color:var(--muted); }
|
|
25
|
+
.sub { color:var(--muted); margin:0 0 1.5rem; }
|
|
26
|
+
.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px;
|
|
27
|
+
padding:1rem 1.2rem; margin:.8rem 0; }
|
|
28
|
+
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
|
|
29
|
+
gap:.7rem; margin:1rem 0; }
|
|
30
|
+
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px;
|
|
31
|
+
padding:.8rem 1rem; }
|
|
32
|
+
.card .num { font-size:1.5rem; font-weight:650; }
|
|
33
|
+
.card .label { color:var(--muted); font-size:.82rem; text-transform:uppercase;
|
|
34
|
+
letter-spacing:.06em; }
|
|
35
|
+
table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
|
36
|
+
th,td { text-align:left; padding:.45rem .55rem; border-bottom:1px solid var(--line);
|
|
37
|
+
vertical-align:top; }
|
|
38
|
+
th { color:var(--muted); font-weight:600; font-size:.78rem; text-transform:uppercase;
|
|
39
|
+
letter-spacing:.05em; }
|
|
40
|
+
tr:last-child td { border-bottom:none; }
|
|
41
|
+
code,pre { font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace; }
|
|
42
|
+
code { background:var(--code); padding:.08rem .3rem; border-radius:4px; }
|
|
43
|
+
pre { background:var(--code); border:1px solid var(--line); border-radius:8px;
|
|
44
|
+
padding:.8rem; overflow:auto; font-size:.82rem; }
|
|
45
|
+
.badge { display:inline-block; padding:.08rem .45rem; border-radius:999px;
|
|
46
|
+
border:1px solid var(--line); font-size:.75rem; font-weight:600; }
|
|
47
|
+
.ok { color:var(--ok); border-color:var(--ok); }
|
|
48
|
+
.warn { color:var(--warn); border-color:var(--warn); }
|
|
49
|
+
.fail { color:var(--fail); border-color:var(--fail); }
|
|
50
|
+
.muted { color:var(--muted); }
|
|
51
|
+
.empty { color:var(--muted); font-style:italic; }
|
|
52
|
+
ul { margin:.3rem 0 .3rem 1.2rem; padding:0; }
|
|
53
|
+
li { margin:.18rem 0; }
|
|
54
|
+
footer { margin-top:2rem; padding-top:1rem; border-top:1px solid var(--line);
|
|
55
|
+
color:var(--muted); font-size:.82rem; }
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _e(value: object) -> str:
|
|
60
|
+
return html.escape("" if value is None else str(value), quote=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _iso(value: object) -> str:
|
|
64
|
+
text = str(value) if value is not None else "-"
|
|
65
|
+
return text.replace("T", " ").replace("+00:00", " UTC")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _duration(value: object) -> str:
|
|
69
|
+
if not isinstance(value, (int, float)):
|
|
70
|
+
return "-"
|
|
71
|
+
return f"{value * 1000:.0f} ms" if value < 1 else f"{value:.2f} s"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _size(value: object) -> str:
|
|
75
|
+
if not isinstance(value, int):
|
|
76
|
+
return "-"
|
|
77
|
+
size = float(value)
|
|
78
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
79
|
+
if size < 1024 or unit == "TB":
|
|
80
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
81
|
+
size /= 1024
|
|
82
|
+
return f"{size:.1f} TB"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _badge(state: str) -> str:
|
|
86
|
+
css = {"ok": "ok", "degraded": "warn", "failed": "fail", "disabled": "muted"}.get(
|
|
87
|
+
state, "muted"
|
|
88
|
+
)
|
|
89
|
+
return f'<span class="badge {css}">{_e(state)}</span>'
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _event_detail(event: dict[str, Any]) -> str:
|
|
93
|
+
payload = event.get("payload")
|
|
94
|
+
if not isinstance(payload, dict):
|
|
95
|
+
return ""
|
|
96
|
+
kind = str(event.get("kind", ""))
|
|
97
|
+
if kind.startswith("filesystem."):
|
|
98
|
+
path = _e(payload.get("path", ""))
|
|
99
|
+
suffix = " (directory)" if payload.get("is_dir") else f" ({_size(payload.get('size'))})"
|
|
100
|
+
return f"<code>{path}</code>{suffix}"
|
|
101
|
+
if kind.startswith("process."):
|
|
102
|
+
pid = _e(payload.get("pid", "?"))
|
|
103
|
+
name = _e(payload.get("name", "unknown"))
|
|
104
|
+
command = _e(payload.get("command", ""))
|
|
105
|
+
extra = _e(payload.get("reason", ""))
|
|
106
|
+
lifetime = payload.get("observed_lifetime_s")
|
|
107
|
+
suffix = f" · {_duration(lifetime)}" if isinstance(lifetime, (int, float)) else ""
|
|
108
|
+
reason = f" · {extra}" if extra else ""
|
|
109
|
+
command_html = f" <code>{command}</code>" if command else ""
|
|
110
|
+
return f"pid {pid} · {name}{command_html}{reason}{suffix}"
|
|
111
|
+
if kind == "network.connection":
|
|
112
|
+
process = _e(payload.get("process_name", "unknown"))
|
|
113
|
+
pid = _e(payload.get("pid", "?"))
|
|
114
|
+
local = _e(payload.get("local_address") or "-")
|
|
115
|
+
remote = _e(payload.get("remote_address") or "listening")
|
|
116
|
+
protocol = _e(payload.get("protocol", "TCP"))
|
|
117
|
+
status = _e(payload.get("status", "UNKNOWN"))
|
|
118
|
+
return (
|
|
119
|
+
f"{process} (pid {pid}) · <code>{local}</code> → <code>{remote}</code> "
|
|
120
|
+
f"· {protocol} · {status}"
|
|
121
|
+
)
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _event_table(events: list[dict[str, Any]]) -> str:
|
|
126
|
+
if not events:
|
|
127
|
+
return '<p class="empty">No events observed in this category.</p>'
|
|
128
|
+
rows = []
|
|
129
|
+
for event in events:
|
|
130
|
+
rows.append(
|
|
131
|
+
"<tr>"
|
|
132
|
+
f"<td><code>{_e(event.get('kind'))}</code></td>"
|
|
133
|
+
f'<td class="muted">{_iso(event.get("occurred_at"))}</td>'
|
|
134
|
+
f"<td>{_event_detail(event)}</td>"
|
|
135
|
+
"</tr>"
|
|
136
|
+
)
|
|
137
|
+
return (
|
|
138
|
+
"<table><thead><tr><th>Event</th><th>Observed at</th><th>Details</th></tr></thead>"
|
|
139
|
+
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def render_html(report: dict[str, Any]) -> str:
|
|
144
|
+
"""Render a report dictionary as a standalone HTML document."""
|
|
145
|
+
|
|
146
|
+
session = report.get("session", {})
|
|
147
|
+
summary = report.get("summary", {})
|
|
148
|
+
collectors = report.get("collectors", [])
|
|
149
|
+
events = report.get("events", [])
|
|
150
|
+
warnings = report.get("warnings", [])
|
|
151
|
+
limitations = report.get("limitations", [])
|
|
152
|
+
if not isinstance(session, dict):
|
|
153
|
+
session = {}
|
|
154
|
+
if not isinstance(summary, dict):
|
|
155
|
+
summary = {}
|
|
156
|
+
if not isinstance(events, list):
|
|
157
|
+
events = []
|
|
158
|
+
if not isinstance(collectors, list):
|
|
159
|
+
collectors = []
|
|
160
|
+
|
|
161
|
+
filesystem = [
|
|
162
|
+
event
|
|
163
|
+
for event in events
|
|
164
|
+
if isinstance(event, dict) and str(event.get("kind", "")).startswith("filesystem.")
|
|
165
|
+
]
|
|
166
|
+
processes = [
|
|
167
|
+
event
|
|
168
|
+
for event in events
|
|
169
|
+
if isinstance(event, dict) and str(event.get("kind", "")).startswith("process.")
|
|
170
|
+
]
|
|
171
|
+
network = [
|
|
172
|
+
event
|
|
173
|
+
for event in events
|
|
174
|
+
if isinstance(event, dict) and str(event.get("kind", "")).startswith("network.")
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
collector_rows = []
|
|
178
|
+
for collector in collectors:
|
|
179
|
+
if not isinstance(collector, dict):
|
|
180
|
+
continue
|
|
181
|
+
collector_rows.append(
|
|
182
|
+
"<tr>"
|
|
183
|
+
f"<td>{_e(collector.get('name'))}</td>"
|
|
184
|
+
f"<td>{_badge(str(collector.get('state', 'unknown')))}</td>"
|
|
185
|
+
f"<td>{_e(collector.get('message') or '')}</td>"
|
|
186
|
+
"</tr>"
|
|
187
|
+
)
|
|
188
|
+
collectors_html = (
|
|
189
|
+
"<table><thead><tr><th>Collector</th><th>State</th><th>Message</th></tr></thead>"
|
|
190
|
+
f"<tbody>{''.join(collector_rows)}</tbody></table>"
|
|
191
|
+
if collector_rows
|
|
192
|
+
else '<p class="empty">No collector status recorded.</p>'
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
warning_html = "".join(f"<li>{_e(item)}</li>" for item in warnings)
|
|
196
|
+
limitation_html = "".join(f"<li>{_e(item)}</li>" for item in limitations)
|
|
197
|
+
events_total = summary.get("events_total", len(events))
|
|
198
|
+
shown_note = ""
|
|
199
|
+
if report.get("events_shown") is not None and len(events) < int(events_total or 0):
|
|
200
|
+
shown_note = (
|
|
201
|
+
f'<p class="muted">Showing {len(events)} of {_e(events_total)} events. '
|
|
202
|
+
"Use --all to include every event.</p>"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
environment_html = ""
|
|
206
|
+
if "environment_diff" in session:
|
|
207
|
+
environment_html = (
|
|
208
|
+
"<h2>Environment (initial child diff, sanitized)</h2>"
|
|
209
|
+
f"<pre>{_e(json.dumps(session.get('environment_diff'), indent=2, ensure_ascii=False))}</pre>"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
options = session.get("options")
|
|
213
|
+
watch_roots = "-"
|
|
214
|
+
if isinstance(options, dict) and isinstance(options.get("watch_roots"), list):
|
|
215
|
+
watch_roots = ", ".join(str(item) for item in options["watch_roots"])
|
|
216
|
+
|
|
217
|
+
title = f"CmdScope report {session.get('ref', '')}".strip()
|
|
218
|
+
return f"""<!doctype html>
|
|
219
|
+
<html lang="en">
|
|
220
|
+
<head>
|
|
221
|
+
<meta charset="utf-8">
|
|
222
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
223
|
+
<title>{_e(title)}</title>
|
|
224
|
+
<style>{_CSS}</style>
|
|
225
|
+
</head>
|
|
226
|
+
<body>
|
|
227
|
+
<main>
|
|
228
|
+
<h1>CmdScope execution report</h1>
|
|
229
|
+
<p class="sub">Session {_e(session.get("ref"))} · generated {_iso(report.get("generated_at"))}</p>
|
|
230
|
+
|
|
231
|
+
<div class="cards">
|
|
232
|
+
<div class="card"><div class="num">{_e(summary.get("filesystem_created"))}</div><div class="label">Files created</div></div>
|
|
233
|
+
<div class="card"><div class="num">{_e(summary.get("filesystem_modified"))}</div><div class="label">Files modified</div></div>
|
|
234
|
+
<div class="card"><div class="num">{_e(summary.get("filesystem_deleted"))}</div><div class="label">Files deleted</div></div>
|
|
235
|
+
<div class="card"><div class="num">{_e(summary.get("processes_started"))}</div><div class="label">Processes started</div></div>
|
|
236
|
+
<div class="card"><div class="num">{_e(summary.get("network_connections"))}</div><div class="label">Network endpoints</div></div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<h2>Session</h2>
|
|
240
|
+
<div class="panel">
|
|
241
|
+
<table>
|
|
242
|
+
<tr><th>Command</th><td><code>{_e(session.get("command_display"))}</code></td></tr>
|
|
243
|
+
<tr><th>Status</th><td>{_badge(str(session.get("status", "unknown")))}</td></tr>
|
|
244
|
+
<tr><th>Exit status</th><td>{_e(session.get("exit_code") if session.get("exit_code") is not None else "-")}</td></tr>
|
|
245
|
+
<tr><th>Directory</th><td><code>{_e(session.get("cwd"))}</code></td></tr>
|
|
246
|
+
<tr><th>Platform</th><td>{_e(session.get("platform"))}</td></tr>
|
|
247
|
+
<tr><th>Started</th><td>{_iso(session.get("started_at") or session.get("created_at"))}</td></tr>
|
|
248
|
+
<tr><th>Duration</th><td>{_duration(session.get("duration_s"))}</td></tr>
|
|
249
|
+
<tr><th>Watch roots</th><td><code>{_e(watch_roots)}</code></td></tr>
|
|
250
|
+
{"<tr><th>Error</th><td><code>" + _e(session.get("error")) + "</code></td></tr>" if session.get("error") else ""}
|
|
251
|
+
</table>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<h2>Filesystem</h2>
|
|
255
|
+
<div class="panel">{_event_table(filesystem)}</div>
|
|
256
|
+
|
|
257
|
+
<h2>Processes</h2>
|
|
258
|
+
<div class="panel">{_event_table(processes)}</div>
|
|
259
|
+
|
|
260
|
+
<h2>Network</h2>
|
|
261
|
+
<div class="panel">{_event_table(network)}</div>
|
|
262
|
+
|
|
263
|
+
<h2>Collectors</h2>
|
|
264
|
+
<div class="panel">{collectors_html}</div>
|
|
265
|
+
|
|
266
|
+
{environment_html}
|
|
267
|
+
|
|
268
|
+
<h2>Limitations</h2>
|
|
269
|
+
<div class="panel"><ul>{limitation_html}</ul></div>
|
|
270
|
+
|
|
271
|
+
<h2>Warnings</h2>
|
|
272
|
+
<div class="panel"><ul>{warning_html or "<li>None recorded.</li>"}</ul></div>
|
|
273
|
+
|
|
274
|
+
{shown_note}
|
|
275
|
+
<footer>Generated locally by CmdScope. No data was transmitted. CmdScope observes; it does not sandbox.</footer>
|
|
276
|
+
</main>
|
|
277
|
+
</body>
|
|
278
|
+
</html>
|
|
279
|
+
"""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""JSON report builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cmdscope.analysis.comparison import ComparisonResult
|
|
11
|
+
from cmdscope.core.events import EventKind, StoredEvent
|
|
12
|
+
from cmdscope.core.session import Session, SessionSummary
|
|
13
|
+
|
|
14
|
+
REPORT_SCHEMA = "cmdscope.report/v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _session_dict(session: Session, *, include_environment: bool) -> dict[str, Any]:
|
|
18
|
+
result: dict[str, Any] = {
|
|
19
|
+
"id": session.id,
|
|
20
|
+
"ref": session.ref,
|
|
21
|
+
"status": str(session.status),
|
|
22
|
+
"command": list(session.command),
|
|
23
|
+
"command_display": session.command_display,
|
|
24
|
+
"cwd": session.cwd,
|
|
25
|
+
"platform": session.platform_name,
|
|
26
|
+
"created_at": session.created_at.isoformat(),
|
|
27
|
+
"started_at": session.started_at.isoformat() if session.started_at else None,
|
|
28
|
+
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
|
|
29
|
+
"duration_s": session.duration_s,
|
|
30
|
+
"exit_code": session.exit_code,
|
|
31
|
+
"interrupted": session.interrupted,
|
|
32
|
+
"error": session.error,
|
|
33
|
+
"options": session.options,
|
|
34
|
+
}
|
|
35
|
+
if include_environment:
|
|
36
|
+
result["environment_diff"] = session.env_diff
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _event_dict(event: StoredEvent) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"id": event.id,
|
|
43
|
+
"kind": str(event.kind),
|
|
44
|
+
"occurred_at": event.occurred_at.isoformat(),
|
|
45
|
+
"source": event.source,
|
|
46
|
+
"payload": event.payload,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_report(
|
|
51
|
+
session: Session,
|
|
52
|
+
summary: SessionSummary,
|
|
53
|
+
events: Sequence[StoredEvent],
|
|
54
|
+
*,
|
|
55
|
+
include_environment: bool = False,
|
|
56
|
+
event_limit: int | None = None,
|
|
57
|
+
generated_at: datetime | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Build a stable JSON-friendly report document."""
|
|
60
|
+
|
|
61
|
+
selected = list(events if event_limit is None else events[:event_limit])
|
|
62
|
+
return {
|
|
63
|
+
"schema": REPORT_SCHEMA,
|
|
64
|
+
"generated_at": (generated_at or datetime.now(UTC)).isoformat(),
|
|
65
|
+
"session": _session_dict(session, include_environment=include_environment),
|
|
66
|
+
"summary": {
|
|
67
|
+
"events_total": summary.events_total,
|
|
68
|
+
"filesystem_created": summary.filesystem_created,
|
|
69
|
+
"filesystem_modified": summary.filesystem_modified,
|
|
70
|
+
"filesystem_deleted": summary.filesystem_deleted,
|
|
71
|
+
"processes_started": summary.processes_started,
|
|
72
|
+
"processes_finished": summary.processes_finished,
|
|
73
|
+
"processes_still_running": summary.processes_still_running,
|
|
74
|
+
"network_connections": summary.network_connections,
|
|
75
|
+
},
|
|
76
|
+
"collectors": [collector.to_dict() for collector in session.collectors],
|
|
77
|
+
"events": [_event_dict(event) for event in selected],
|
|
78
|
+
"events_shown": len(selected),
|
|
79
|
+
"limitations": [
|
|
80
|
+
"Only changes under the configured watch roots are observed.",
|
|
81
|
+
"Processes and network sockets are sampled; short-lived ones can be missed.",
|
|
82
|
+
"File contents are never captured.",
|
|
83
|
+
"CmdScope is an observability tool, not a sandbox or isolation boundary.",
|
|
84
|
+
"Runtime environment mutations are not observable externally.",
|
|
85
|
+
],
|
|
86
|
+
"warnings": list(summary.warnings),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_json(report: dict[str, Any]) -> str:
|
|
91
|
+
"""Serialize a report deterministically for terminals and files."""
|
|
92
|
+
|
|
93
|
+
return json.dumps(report, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_comparison_report(result: ComparisonResult) -> dict[str, Any]:
|
|
97
|
+
"""Build the JSON document used by ``cmdscope compare --format json``."""
|
|
98
|
+
|
|
99
|
+
def item_dict(item: Any) -> dict[str, str]:
|
|
100
|
+
return {
|
|
101
|
+
"category": item.category,
|
|
102
|
+
"action": item.action,
|
|
103
|
+
"label": item.label,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"schema": "cmdscope.comparison/v1",
|
|
108
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
109
|
+
"session_a": {
|
|
110
|
+
"id": result.session_a.id,
|
|
111
|
+
"ref": result.session_a.ref,
|
|
112
|
+
"command": result.session_a.command_display,
|
|
113
|
+
"events_total": result.events_a,
|
|
114
|
+
},
|
|
115
|
+
"session_b": {
|
|
116
|
+
"id": result.session_b.id,
|
|
117
|
+
"ref": result.session_b.ref,
|
|
118
|
+
"command": result.session_b.command_display,
|
|
119
|
+
"events_total": result.events_b,
|
|
120
|
+
},
|
|
121
|
+
"common_count": result.common_count,
|
|
122
|
+
"only_in_a": [item_dict(item) for item in result.only_in_a],
|
|
123
|
+
"only_in_b": [item_dict(item) for item in result.only_in_b],
|
|
124
|
+
"note": "Comparison uses normalized paths and event signatures; timestamps are ignored.",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_comparison_json(result: ComparisonResult) -> str:
|
|
129
|
+
return json.dumps(build_comparison_report(result), indent=2, ensure_ascii=False) + "\n"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_network_event(event: StoredEvent) -> bool:
|
|
133
|
+
return event.kind == EventKind.NETWORK_CONNECTION
|