cyber-shell-wrapper 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.
@@ -0,0 +1,5 @@
1
+ """Cyber Shell package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shlex
5
+ from dataclasses import dataclass
6
+
7
+ from .config import AppConfig
8
+ from .models import ShellEvent
9
+
10
+
11
+ INTERACTIVE_COMMANDS = {
12
+ "alsamixer",
13
+ "ftp",
14
+ "htop",
15
+ "less",
16
+ "man",
17
+ "more",
18
+ "mongo",
19
+ "mysql",
20
+ "nano",
21
+ "nmtui",
22
+ "psql",
23
+ "python",
24
+ "python3",
25
+ "redis-cli",
26
+ "sftp",
27
+ "ssh",
28
+ "sqlite3",
29
+ "telnet",
30
+ "tig",
31
+ "tmux",
32
+ "top",
33
+ "vi",
34
+ "view",
35
+ "vim",
36
+ "watch",
37
+ }
38
+
39
+ PREFIX_WRAPPERS = {
40
+ "builtin",
41
+ "chronic",
42
+ "command",
43
+ "env",
44
+ "exec",
45
+ "nohup",
46
+ "stdbuf",
47
+ "time",
48
+ }
49
+
50
+ ANSI_ESCAPE_RE = re.compile(
51
+ r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x1b\x07]*(?:\x07|\x1b\\))"
52
+ )
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class ActiveCommand:
57
+ started_at: str
58
+ cmd: str
59
+ output_buffer: bytearray
60
+ truncated: bool = False
61
+
62
+
63
+ class EventAssembler:
64
+ def __init__(self, config: AppConfig, session_id: str) -> None:
65
+ self._config = config
66
+ self._session_id = session_id
67
+ self._seq = 0
68
+ self._current: ActiveCommand | None = None
69
+
70
+ def start_command(self, started_at: str, cmd: str) -> None:
71
+ self._current = ActiveCommand(
72
+ started_at=started_at,
73
+ cmd=cmd.strip(),
74
+ output_buffer=bytearray(),
75
+ )
76
+
77
+ def append_output(self, chunk: bytes) -> None:
78
+ if self._current is None or not chunk:
79
+ return
80
+ remaining = self._config.max_output_bytes - len(self._current.output_buffer)
81
+ if remaining <= 0:
82
+ self._current.truncated = True
83
+ return
84
+ self._current.output_buffer.extend(chunk[:remaining])
85
+ if len(chunk) > remaining:
86
+ self._current.truncated = True
87
+
88
+ def finish_command(
89
+ self,
90
+ *,
91
+ finished_at: str,
92
+ exit_code: int,
93
+ cwd: str,
94
+ ) -> ShellEvent | None:
95
+ current = self._current
96
+ self._current = None
97
+ if current is None or not current.cmd:
98
+ return None
99
+
100
+ self._seq += 1
101
+ return ShellEvent(
102
+ session_id=self._session_id,
103
+ hostname=self._config.hostname,
104
+ shell="bash",
105
+ seq=self._seq,
106
+ cwd=cwd,
107
+ cmd=current.cmd,
108
+ exit_code=exit_code,
109
+ output=_sanitize_output(
110
+ current.output_buffer.decode("utf-8", errors="replace")
111
+ ),
112
+ output_truncated=current.truncated,
113
+ started_at=current.started_at,
114
+ finished_at=finished_at,
115
+ is_interactive=is_interactive_command(current.cmd),
116
+ metadata=dict(self._config.metadata),
117
+ )
118
+
119
+
120
+ def is_interactive_command(command: str) -> bool:
121
+ executable = _extract_command_name(command)
122
+ if executable is None:
123
+ return False
124
+ return executable in INTERACTIVE_COMMANDS
125
+
126
+
127
+ def _extract_command_name(command: str) -> str | None:
128
+ try:
129
+ tokens = shlex.split(command, posix=True)
130
+ except ValueError:
131
+ return None
132
+
133
+ index = 0
134
+ while index < len(tokens):
135
+ token = tokens[index]
136
+ if _looks_like_env_assignment(token):
137
+ index += 1
138
+ continue
139
+ if token == "sudo":
140
+ index += 1
141
+ while index < len(tokens):
142
+ sudo_token = tokens[index]
143
+ if sudo_token == "--":
144
+ index += 1
145
+ break
146
+ if not sudo_token.startswith("-"):
147
+ break
148
+ index += 1
149
+ if sudo_token in {"-g", "-h", "-p", "-u"} and index < len(tokens):
150
+ index += 1
151
+ continue
152
+ if token == "env":
153
+ index += 1
154
+ while index < len(tokens) and _looks_like_env_assignment(tokens[index]):
155
+ index += 1
156
+ continue
157
+ if token in PREFIX_WRAPPERS:
158
+ index += 1
159
+ continue
160
+ return token
161
+ return None
162
+
163
+
164
+ def _looks_like_env_assignment(token: str) -> bool:
165
+ if "=" not in token or token.startswith("="):
166
+ return False
167
+ name, _ = token.split("=", 1)
168
+ return name.replace("_", "A").isalnum()
169
+
170
+
171
+ def _sanitize_output(value: str) -> str:
172
+ cleaned = ANSI_ESCAPE_RE.sub("", value)
173
+ cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
174
+ return cleaned
cyber_shell/cli.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from .config import default_config_text, has_runtime_overrides, load_config, persist_config
7
+ from .logging_utils import configure_logging
8
+ from .mock_endpoint import run_mock_endpoint
9
+ from .telemetry import TelemetryClient
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="cyber-shell",
15
+ description="Local PTY shell wrapper for cyber range telemetry.",
16
+ )
17
+ parser.add_argument(
18
+ "--config",
19
+ help="Global config path shortcut for default start command.",
20
+ )
21
+ parser.add_argument(
22
+ "--endpoint-url",
23
+ help="Override telemetry endpoint for the wrapped shell session.",
24
+ )
25
+ parser.add_argument(
26
+ "--api-key",
27
+ help="Override telemetry API key for the wrapped shell session.",
28
+ )
29
+
30
+ subparsers = parser.add_subparsers(dest="command")
31
+
32
+ start_parser = subparsers.add_parser("start", help="Start wrapped Bash session.")
33
+ start_parser.add_argument(
34
+ "--config",
35
+ help="Path to config.yaml. Defaults to ~/.config/cyber-shell/config.yaml",
36
+ )
37
+ start_parser.add_argument(
38
+ "--endpoint-url",
39
+ help="Override telemetry endpoint for this session.",
40
+ )
41
+ start_parser.add_argument(
42
+ "--api-key",
43
+ help="Override telemetry API key for this session.",
44
+ )
45
+
46
+ mock_parser = subparsers.add_parser(
47
+ "mock-endpoint",
48
+ help="Run a local mock telemetry endpoint.",
49
+ )
50
+ mock_parser.add_argument("--host", default="127.0.0.1")
51
+ mock_parser.add_argument("--port", type=int, default=8080)
52
+ mock_parser.add_argument("--api-key")
53
+
54
+ subparsers.add_parser(
55
+ "print-default-config",
56
+ help="Print a default config.yaml template.",
57
+ )
58
+
59
+ return parser
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> int:
63
+ parser = build_parser()
64
+ args = parser.parse_args(argv)
65
+ command = args.command or "start"
66
+
67
+ if command == "print-default-config":
68
+ sys.stdout.write(default_config_text())
69
+ return 0
70
+
71
+ if command == "mock-endpoint":
72
+ return run_mock_endpoint(args.host, args.port, args.api_key)
73
+
74
+ config_path = getattr(args, "config", None)
75
+ config = load_config(config_path)
76
+ if getattr(args, "endpoint_url", None):
77
+ config.endpoint_url = args.endpoint_url
78
+ if getattr(args, "api_key", None):
79
+ config.api_key = args.api_key
80
+ logger = configure_logging(config.state_dir)
81
+
82
+ if command == "start":
83
+ if has_runtime_overrides(
84
+ {
85
+ "endpoint_url": getattr(args, "endpoint_url", None),
86
+ "api_key": getattr(args, "api_key", None),
87
+ }
88
+ ):
89
+ persisted_path = persist_config(config)
90
+ print(
91
+ f"cyber-shell: updated config -> {persisted_path}",
92
+ file=sys.stderr,
93
+ )
94
+ if config.endpoint_url:
95
+ print(
96
+ f"cyber-shell: telemetry -> {config.endpoint_url}",
97
+ file=sys.stderr,
98
+ )
99
+ else:
100
+ print(
101
+ "cyber-shell: telemetry disabled; set endpoint_url in config, "
102
+ "export CYBER_SHELL_ENDPOINT_URL, or pass --endpoint-url",
103
+ file=sys.stderr,
104
+ )
105
+ if config.endpoint_url and not config.api_key:
106
+ print(
107
+ "cyber-shell: telemetry has no API key; pass --api-key or "
108
+ "export CYBER_SHELL_API_KEY if the endpoint requires auth",
109
+ file=sys.stderr,
110
+ )
111
+ from .shell_wrapper import ShellWrapper
112
+
113
+ telemetry = TelemetryClient(config, logger)
114
+ try:
115
+ wrapper = ShellWrapper(config, telemetry, logger)
116
+ return wrapper.run()
117
+ finally:
118
+ telemetry.close()
119
+
120
+ parser.print_help()
121
+ return 1
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
cyber_shell/config.py ADDED
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import socket
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "cyber-shell" / "config.yaml"
10
+ DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "cyber-shell"
11
+ PERSISTED_ENV_KEYS = {
12
+ "CYBER_SHELL_CONFIG",
13
+ "CYBER_SHELL_ENDPOINT_URL",
14
+ "CYBER_SHELL_API_KEY",
15
+ "CYBER_SHELL_TIMEOUT_MS",
16
+ "CYBER_SHELL_RETRY_MAX",
17
+ "CYBER_SHELL_RETRY_BACKOFF_MS",
18
+ "CYBER_SHELL_MAX_OUTPUT_BYTES",
19
+ "CYBER_SHELL_QUEUE_SIZE",
20
+ "CYBER_SHELL_SHELL_PATH",
21
+ "CYBER_SHELL_HOSTNAME",
22
+ "CYBER_SHELL_STATE_DIR",
23
+ }
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class AppConfig:
28
+ endpoint_url: str | None = None
29
+ api_key: str | None = None
30
+ timeout_ms: int = 3000
31
+ retry_max: int = 3
32
+ retry_backoff_ms: int = 1000
33
+ max_output_bytes: int = 262144
34
+ queue_size: int = 256
35
+ shell_path: str = "/bin/bash"
36
+ state_dir: Path = DEFAULT_STATE_DIR
37
+ config_path: Path = DEFAULT_CONFIG_PATH
38
+ hostname: str = field(default_factory=socket.gethostname)
39
+ metadata: dict[str, str] = field(default_factory=dict)
40
+
41
+ def ensure_state_dir(self) -> Path:
42
+ self.state_dir.mkdir(parents=True, exist_ok=True)
43
+ return self.state_dir
44
+
45
+
46
+ def load_config(path: str | Path | None = None) -> AppConfig:
47
+ config_path = Path(
48
+ path
49
+ or os.environ.get("CYBER_SHELL_CONFIG")
50
+ or DEFAULT_CONFIG_PATH
51
+ ).expanduser()
52
+ data: dict[str, object] = {}
53
+ if config_path.exists():
54
+ data = _parse_simple_yaml(config_path.read_text(encoding="utf-8"))
55
+
56
+ config = AppConfig(
57
+ endpoint_url=_env_or_data("CYBER_SHELL_ENDPOINT_URL", data, "endpoint_url"),
58
+ api_key=_env_or_data("CYBER_SHELL_API_KEY", data, "api_key"),
59
+ timeout_ms=_coerce_int(
60
+ _env_or_data("CYBER_SHELL_TIMEOUT_MS", data, "timeout_ms"), 3000
61
+ ),
62
+ retry_max=_coerce_int(
63
+ _env_or_data("CYBER_SHELL_RETRY_MAX", data, "retry_max"), 3
64
+ ),
65
+ retry_backoff_ms=_coerce_int(
66
+ _env_or_data("CYBER_SHELL_RETRY_BACKOFF_MS", data, "retry_backoff_ms"),
67
+ 1000,
68
+ ),
69
+ max_output_bytes=_coerce_int(
70
+ _env_or_data("CYBER_SHELL_MAX_OUTPUT_BYTES", data, "max_output_bytes"),
71
+ 262144,
72
+ ),
73
+ queue_size=_coerce_int(
74
+ _env_or_data("CYBER_SHELL_QUEUE_SIZE", data, "queue_size"), 256
75
+ ),
76
+ shell_path=str(
77
+ _env_or_data("CYBER_SHELL_SHELL_PATH", data, "shell_path") or "/bin/bash"
78
+ ),
79
+ state_dir=Path(
80
+ _env_or_data("CYBER_SHELL_STATE_DIR", data, "state_dir")
81
+ or DEFAULT_STATE_DIR
82
+ ).expanduser(),
83
+ config_path=config_path,
84
+ hostname=str(
85
+ _env_or_data("CYBER_SHELL_HOSTNAME", data, "hostname")
86
+ or socket.gethostname()
87
+ ),
88
+ metadata=_coerce_metadata(data.get("metadata")),
89
+ )
90
+ config.ensure_state_dir()
91
+ return config
92
+
93
+
94
+ def default_config_text() -> str:
95
+ return _serialize_config(
96
+ AppConfig(
97
+ endpoint_url="http://127.0.0.1:8080/api/terminal-events",
98
+ api_key="replace-me",
99
+ timeout_ms=3000,
100
+ retry_max=3,
101
+ retry_backoff_ms=1000,
102
+ max_output_bytes=262144,
103
+ queue_size=256,
104
+ shell_path="/bin/bash",
105
+ metadata={"hostname_group": "kali-lab"},
106
+ )
107
+ )
108
+
109
+
110
+ def persist_config(config: AppConfig) -> Path:
111
+ config.config_path.parent.mkdir(parents=True, exist_ok=True)
112
+ config.config_path.write_text(_serialize_config(config), encoding="utf-8")
113
+ if os.name == "posix":
114
+ os.chmod(config.config_path, 0o600)
115
+ return config.config_path
116
+
117
+
118
+ def has_runtime_overrides(cli_args: dict[str, object] | None = None) -> bool:
119
+ if cli_args:
120
+ if cli_args.get("endpoint_url") or cli_args.get("api_key"):
121
+ return True
122
+ return any(key in os.environ for key in PERSISTED_ENV_KEYS)
123
+
124
+
125
+ def _env_or_data(env_name: str, data: dict[str, object], key: str) -> object:
126
+ if env_name in os.environ:
127
+ return os.environ[env_name]
128
+ return data.get(key)
129
+
130
+
131
+ def _coerce_int(value: object, default: int) -> int:
132
+ if value in (None, ""):
133
+ return default
134
+ try:
135
+ return int(str(value))
136
+ except ValueError:
137
+ return default
138
+
139
+
140
+ def _coerce_metadata(value: object) -> dict[str, str]:
141
+ if not isinstance(value, dict):
142
+ return {}
143
+ return {str(key): str(inner) for key, inner in value.items()}
144
+
145
+
146
+ def _serialize_config(config: AppConfig) -> str:
147
+ lines = [
148
+ f'endpoint_url: {_yaml_string(config.endpoint_url)}'
149
+ if config.endpoint_url is not None
150
+ else "endpoint_url: null",
151
+ f'api_key: {_yaml_string(config.api_key)}'
152
+ if config.api_key is not None
153
+ else "api_key: null",
154
+ f"timeout_ms: {config.timeout_ms}",
155
+ f"retry_max: {config.retry_max}",
156
+ f"retry_backoff_ms: {config.retry_backoff_ms}",
157
+ f"max_output_bytes: {config.max_output_bytes}",
158
+ f"queue_size: {config.queue_size}",
159
+ f'shell_path: {_yaml_string(config.shell_path)}',
160
+ ]
161
+ if config.metadata:
162
+ lines.append("metadata:")
163
+ for key, value in config.metadata.items():
164
+ lines.append(f" {key}: {_yaml_string(value)}")
165
+ return "\n".join(lines) + "\n"
166
+
167
+
168
+ def _yaml_string(value: object) -> str:
169
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
170
+ return f'"{escaped}"'
171
+
172
+
173
+ def _parse_simple_yaml(text: str) -> dict[str, object]:
174
+ result: dict[str, object] = {}
175
+ stack: list[tuple[int, dict[str, object]]] = [(-1, result)]
176
+
177
+ for raw_line in text.splitlines():
178
+ if not raw_line.strip():
179
+ continue
180
+ stripped = raw_line.lstrip()
181
+ if stripped.startswith("#"):
182
+ continue
183
+
184
+ indent = len(raw_line) - len(stripped)
185
+ while len(stack) > 1 and indent <= stack[-1][0]:
186
+ stack.pop()
187
+
188
+ if ":" not in stripped:
189
+ continue
190
+ key, raw_value = stripped.split(":", 1)
191
+ key = key.strip()
192
+ value = raw_value.strip()
193
+
194
+ parent = stack[-1][1]
195
+ if not value:
196
+ child: dict[str, object] = {}
197
+ parent[key] = child
198
+ stack.append((indent, child))
199
+ continue
200
+
201
+ parent[key] = _parse_scalar(value)
202
+
203
+ return result
204
+
205
+
206
+ def _parse_scalar(value: str) -> object:
207
+ if value.startswith(("'", '"')) and value.endswith(("'", '"')) and len(value) >= 2:
208
+ return value[1:-1]
209
+ lowered = value.lower()
210
+ if lowered == "true":
211
+ return True
212
+ if lowered == "false":
213
+ return False
214
+ if lowered in {"null", "none"}:
215
+ return None
216
+ try:
217
+ return int(value)
218
+ except ValueError:
219
+ return value
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+ from pathlib import Path
6
+
7
+
8
+ def configure_logging(state_dir: Path) -> logging.Logger:
9
+ logger = logging.getLogger("cyber-shell")
10
+ if logger.handlers:
11
+ return logger
12
+
13
+ logger.setLevel(logging.INFO)
14
+ handler = RotatingFileHandler(
15
+ state_dir / "cyber-shell.log",
16
+ maxBytes=1_000_000,
17
+ backupCount=3,
18
+ encoding="utf-8",
19
+ )
20
+ handler.setFormatter(
21
+ logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
22
+ )
23
+ logger.addHandler(handler)
24
+ logger.propagate = False
25
+ return logger