standkit 0.3.7__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,93 @@
1
+ """
2
+ Структурный append-only аудит-лог агента (JSON-lines) — обязателен для
3
+ DevSecOps-эксплуатации headless-агента, управляющего процессами на хосте
4
+ стенда.
5
+
6
+ Формат одной строки (JSON-объект, без вложенных переводов строк):
7
+ {
8
+ "ts": "2026-07-23T12:34:56.789012+00:00", # UTC ISO-8601
9
+ "src_ip": "10.0.0.5",
10
+ "identity": "control", # control|readonly|<CN>|"-"
11
+ "method": "POST",
12
+ "path": "/stand/demo/restart",
13
+ "action": "restart",
14
+ "result": "ok", # ok|denied|error
15
+ "code": 200
16
+ }
17
+
18
+ Секреты/токены НИКОГДА не попадают в аудит-запись — только идентичность
19
+ уровня "какой скоуп/CN использован", не сам токен.
20
+
21
+ STDLIB-ONLY: используется ``logging`` с отдельным именованным логгером и
22
+ файловым хендлером (не корневой логгер — чтобы не смешиваться с чужой
23
+ конфигурацией логирования встраивающего процесса).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Optional
33
+
34
+ DEFAULT_AUDIT_LOG_PATH = Path.home() / ".standkit" / "audit.log"
35
+
36
+ _LOGGER_NAME = "standkit_agent.audit"
37
+
38
+
39
+ def build_audit_logger(path: Optional[Path] = None) -> logging.Logger:
40
+ """
41
+ Возвращает (создавая при необходимости) файловый логгер аудита.
42
+
43
+ Идемпотентно: повторный вызов с тем же путём не плодит дублирующиеся
44
+ хендлеры (полезно в тестах, где ``run_server``/фабрики обработчика могут
45
+ вызываться многократно в одном процессе).
46
+ """
47
+ p = Path(path) if path else DEFAULT_AUDIT_LOG_PATH
48
+ p.parent.mkdir(parents=True, exist_ok=True)
49
+
50
+ logger_name = f"{_LOGGER_NAME}.{abs(hash(str(p.resolve())))}"
51
+ logger = logging.getLogger(logger_name)
52
+ logger.setLevel(logging.INFO)
53
+ logger.propagate = False # не утекает в root-логгер встраивающего процесса
54
+
55
+ already_attached = any(
56
+ isinstance(h, logging.FileHandler) and Path(h.baseFilename) == p.resolve()
57
+ for h in logger.handlers
58
+ )
59
+ if not already_attached:
60
+ handler = logging.FileHandler(p, encoding="utf-8")
61
+ handler.setFormatter(logging.Formatter("%(message)s"))
62
+ logger.addHandler(handler)
63
+ return logger
64
+
65
+
66
+ def audit_event(
67
+ logger: logging.Logger,
68
+ *,
69
+ src_ip: str,
70
+ identity: str,
71
+ method: str,
72
+ path: str,
73
+ action: str,
74
+ result: str,
75
+ code: int,
76
+ ) -> None:
77
+ """Пишет одну JSON-строку аудита. Никогда не бросает исключений наружу."""
78
+ entry = {
79
+ "ts": datetime.now(timezone.utc).isoformat(),
80
+ "src_ip": src_ip,
81
+ "identity": identity or "-",
82
+ "method": method,
83
+ "path": path,
84
+ "action": action,
85
+ "result": result,
86
+ "code": code,
87
+ }
88
+ try:
89
+ logger.info(json.dumps(entry, ensure_ascii=False))
90
+ except Exception:
91
+ # Аудит-лог не должен ронять обработку запроса ни при каких
92
+ # обстоятельствах (диск полон, права на файл и т.п.).
93
+ pass
@@ -0,0 +1,300 @@
1
+ """
2
+ Security-примитивы headless-агента: fail-closed валидация bind-параметров,
3
+ TLS/mTLS-контекст, аутентификация по скоупам (control/readonly) с
4
+ защитой от timing-атак, rate limiting/lockout по source-IP.
5
+
6
+ Агент — RCE-поверхность по дизайну (управляет процессами на хосте стенда:
7
+ start/stop/restart произвольного дистрибутива). Все функции этого модуля
8
+ написаны так, чтобы secure-defaults были ОТКАЗ, а не разрешение (fail-closed):
9
+ там, где нет однозначного подтверждения безопасности конфигурации — агент не
10
+ стартует, а не "стартует и предупреждает".
11
+
12
+ STDLIB-ONLY: ``ssl``, ``hmac``, ``socket``, ``threading``, ``time``, ``re``.
13
+ Никаких сторонних зависимостей.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hmac
19
+ import re
20
+ import ssl
21
+ import threading
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from typing import Optional
25
+
26
+ # --- Константы secure-defaults ---
27
+
28
+ # Минимум TLS 1.2 — TLS 1.0/1.1 запрещены протоколом (POODLE/BEAST-класс атак).
29
+ MIN_TLS_VERSION = ssl.TLSVersion.TLSv1_2
30
+
31
+ # Разумный набор современных AEAD-шифров (без RC4/3DES/статического RSA-обмена
32
+ # ключами, без экспортных наборов). Список сознательно консервативен —
33
+ # приоритет ECDHE (forward secrecy) + AEAD.
34
+ RECOMMENDED_CIPHERS = (
35
+ "ECDHE-ECDSA-AES256-GCM-SHA384:"
36
+ "ECDHE-RSA-AES256-GCM-SHA384:"
37
+ "ECDHE-ECDSA-AES128-GCM-SHA256:"
38
+ "ECDHE-RSA-AES128-GCM-SHA256:"
39
+ "ECDHE-ECDSA-CHACHA20-POLY1305:"
40
+ "ECDHE-RSA-CHACHA20-POLY1305"
41
+ )
42
+
43
+ DEFAULT_MAX_BODY_BYTES = 64 * 1024 # 64 КБ — лимит тела HTTP-запроса
44
+ DEFAULT_MAX_LOGS_N = 10_000 # кап на "n" в GET /stand/{name}/logs
45
+ DEFAULT_SOCKET_TIMEOUT = 30.0 # секунд — таймаут на одно соединение
46
+
47
+ DEFAULT_LOCKOUT_MAX_FAILURES = 5
48
+ DEFAULT_LOCKOUT_WINDOW_SECONDS = 300.0
49
+
50
+ _LOOPBACK_EXACT = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"}
51
+
52
+ # Валидное имя стенда: то же множество символов, что типично для ключей
53
+ # реестра projects.json (без "/", без пробелов, без управляющих символов).
54
+ _STAND_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
55
+
56
+ # --- Скоупы аутентификации ---
57
+
58
+ SCOPE_CONTROL = "control"
59
+ SCOPE_READONLY = "readonly"
60
+
61
+ READ_ACTIONS = frozenset({"stands", "status", "logs"})
62
+ CONTROL_ACTIONS = frozenset({"start", "stop", "restart"})
63
+
64
+
65
+ class InsecureBindError(Exception):
66
+ """
67
+ Отказ старта агента: fail-closed проверка bind-параметров не пройдена.
68
+
69
+ Бросается ДО открытия сокета — старт процесса агента прерывается с
70
+ ненулевым кодом возврата, а не "стартует и уязвим".
71
+ """
72
+
73
+
74
+ def is_loopback_host(host: str) -> bool:
75
+ """
76
+ True, если ``host`` — loopback-адрес/имя (127.0.0.1, ::1, localhost,
77
+ 127.x.x.x). Используется для fail-closed решения — держим проверку
78
+ консервативной (белый список известных loopback-форм), а не пытаемся
79
+ резолвить произвольные DNS-имена (это была бы сетевая операция внутри
80
+ чистой функции валидации).
81
+ """
82
+ h = (host or "").strip().lower()
83
+ if h in _LOOPBACK_EXACT:
84
+ return True
85
+ if h.startswith("127."):
86
+ return True
87
+ return False
88
+
89
+
90
+ def validate_bind_security(host: str, *, tls_enabled: bool, insecure: bool) -> None:
91
+ """
92
+ Fail-closed проверка перед стартом сервера. Чистая функция — НЕ открывает
93
+ сокет, поэтому тестируется без реальной сети.
94
+
95
+ Правила:
96
+ - loopback-хост — открытый HTTP разрешён (обычный dev-сценарий за
97
+ локальным туннелем/управляющим контуром на той же машине);
98
+ - non-loopback хост БЕЗ TLS — отказ (``InsecureBindError``), если не
99
+ передан явный ``insecure=True`` (осознанный обход, только для
100
+ dev/тестовых сценариев — вызывающая сторона обязана громко
101
+ предупредить в stderr, см. ``standkit_agent.server.run_server``);
102
+ - TLS включён — non-loopback разрешён без дополнительных условий
103
+ (транспорт уже защищён).
104
+ """
105
+ if tls_enabled:
106
+ return
107
+ if is_loopback_host(host):
108
+ return
109
+ if insecure:
110
+ return
111
+ raise InsecureBindError(
112
+ f"Отказ старта: host={host!r} не loopback, TLS не настроен "
113
+ "(--tls-cert/--tls-key) — headless-агент управляет процессами на "
114
+ "хосте стенда (RCE-поверхность), открытый HTTP наружу по умолчанию "
115
+ "запрещён (fail-closed). Варианты: (1) слушать loopback (127.0.0.1) "
116
+ "за управляющим контуром/VPN/SSH-туннелем; (2) настроить TLS/mTLS "
117
+ "(--tls-cert/--tls-key[/--tls-client-ca]); (3) если это осознанный "
118
+ "dev-сценарий — передать --insecure (НЕ для прод, агент выведет "
119
+ "громкое предупреждение)."
120
+ )
121
+
122
+
123
+ def _apply_tls_hardening(context: ssl.SSLContext, *, require_client_cert: bool) -> None:
124
+ """
125
+ Настраивает флаги ``SSLContext`` (минимальная версия протокола, шифры,
126
+ verify_mode) БЕЗ обращения к файловой системе — специально вынесено
127
+ отдельно от ``build_ssl_context``, чтобы это можно было протестировать
128
+ юнит-тестом без реальных сертификатов (см. tests/test_agent_security.py).
129
+ """
130
+ context.minimum_version = MIN_TLS_VERSION
131
+ try:
132
+ context.set_ciphers(RECOMMENDED_CIPHERS)
133
+ except ssl.SSLError:
134
+ # Набор шифров недоступен в конкретной сборке OpenSSL — не роняем
135
+ # запуск агента из-за этого, минимальная версия протокола важнее.
136
+ pass
137
+ # Явно выключаем сжатие (CRIME-класс атак) и устаревшие опции, если они
138
+ # доступны в текущей сборке OpenSSL.
139
+ context.options |= getattr(ssl, "OP_NO_COMPRESSION", 0)
140
+ if require_client_cert:
141
+ context.verify_mode = ssl.CERT_REQUIRED
142
+ else:
143
+ context.verify_mode = ssl.CERT_NONE
144
+
145
+
146
+ def build_ssl_context(
147
+ tls_cert: str,
148
+ tls_key: str,
149
+ tls_client_ca: Optional[str] = None,
150
+ ) -> ssl.SSLContext:
151
+ """
152
+ Строит серверный ``SSLContext`` для оборачивания сокета агента.
153
+
154
+ Если задан ``tls_client_ca`` — включается mTLS: клиенты БЕЗ сертификата,
155
+ подписанного этим CA, отклоняются на уровне TLS-хендшейка, до того как
156
+ запрос доходит до обработчика (``CERT_REQUIRED`` +
157
+ ``load_verify_locations``).
158
+ """
159
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
160
+ _apply_tls_hardening(context, require_client_cert=bool(tls_client_ca))
161
+ context.load_cert_chain(certfile=tls_cert, keyfile=tls_key)
162
+ if tls_client_ca:
163
+ context.load_verify_locations(cafile=tls_client_ca)
164
+ return context
165
+
166
+
167
+ def peer_identity_from_cert(peer_cert: Optional[dict]) -> Optional[str]:
168
+ """
169
+ Извлекает CN клиентского сертификата из результата
170
+ ``SSLSocket.getpeercert()`` — для аудита (кто предъявил mTLS-сертификат).
171
+
172
+ Возвращает ``None``, если сертификата нет или CN не найден (не бросает
173
+ исключений — это вспомогательная функция для логирования, а не для
174
+ решений о доступе).
175
+ """
176
+ if not peer_cert:
177
+ return None
178
+ for rdn in peer_cert.get("subject", ()):
179
+ for key, value in rdn:
180
+ if key == "commonName":
181
+ return value
182
+ return None
183
+
184
+
185
+ class Authenticator:
186
+ """
187
+ Проверка Bearer-токена запроса со скоупом control/readonly.
188
+
189
+ Сравнение — ТОЛЬКО через ``hmac.compare_digest`` (защита от timing-атак
190
+ по времени сравнения строк символ-за-символом).
191
+ """
192
+
193
+ def __init__(self, control_token: Optional[str], readonly_token: Optional[str] = None):
194
+ self._control_token = control_token or None
195
+ self._readonly_token = readonly_token or None
196
+
197
+ def check(self, presented_token: Optional[str]) -> Optional[str]:
198
+ """
199
+ Возвращает ``SCOPE_CONTROL``/``SCOPE_READONLY`` при совпадении токена,
200
+ иначе ``None``. Пустой/отсутствующий предъявленный токен — всегда
201
+ отказ (fail-closed), без обращения к ``hmac.compare_digest``.
202
+ """
203
+ if not presented_token:
204
+ return None
205
+ if self._control_token and hmac.compare_digest(presented_token, self._control_token):
206
+ return SCOPE_CONTROL
207
+ if self._readonly_token and hmac.compare_digest(presented_token, self._readonly_token):
208
+ return SCOPE_READONLY
209
+ return None
210
+
211
+ @staticmethod
212
+ def scope_allows(scope: Optional[str], action: str) -> bool:
213
+ """
214
+ Проверяет, достаточно ли ``scope`` для выполнения ``action``.
215
+
216
+ control-скоуп разрешает всё; readonly — только действия из
217
+ ``READ_ACTIONS``; отсутствие скоупа (``None``) не разрешает ничего.
218
+ """
219
+ if scope == SCOPE_CONTROL:
220
+ return True
221
+ if scope == SCOPE_READONLY:
222
+ return action in READ_ACTIONS
223
+ return False
224
+
225
+
226
+ @dataclass
227
+ class LockoutTracker:
228
+ """
229
+ Потокобезопасный счётчик неудачных аутентификаций per source-IP.
230
+
231
+ После ``max_failures`` неудач в скользящем окне ``window_seconds`` —
232
+ IP считается заблокированным (``is_locked`` → True) до истечения окна с
233
+ момента последней неудачи, входящей в счёт. Рассчитан на использование
234
+ из ``ThreadingHTTPServer`` (несколько потоков-обработчиков одновременно).
235
+ """
236
+
237
+ max_failures: int = DEFAULT_LOCKOUT_MAX_FAILURES
238
+ window_seconds: float = DEFAULT_LOCKOUT_WINDOW_SECONDS
239
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
240
+ _failures: dict = field(default_factory=dict, repr=False, compare=False)
241
+
242
+ def _prune(self, ip: str, now: float) -> list:
243
+ hits = [t for t in self._failures.get(ip, []) if now - t < self.window_seconds]
244
+ self._failures[ip] = hits
245
+ return hits
246
+
247
+ def is_locked(self, ip: str) -> bool:
248
+ with self._lock:
249
+ hits = self._prune(ip, time.monotonic())
250
+ return len(hits) >= self.max_failures
251
+
252
+ def record_failure(self, ip: str) -> None:
253
+ with self._lock:
254
+ now = time.monotonic()
255
+ hits = self._prune(ip, now)
256
+ hits.append(now)
257
+ self._failures[ip] = hits
258
+
259
+ def record_success(self, ip: str) -> None:
260
+ with self._lock:
261
+ self._failures.pop(ip, None)
262
+
263
+
264
+ def validate_stand_name(name: str) -> bool:
265
+ """Валидация имени стенда из URL — консервативный whitelist символов, кап длины."""
266
+ return bool(name) and bool(_STAND_NAME_RE.match(name))
267
+
268
+
269
+ def clamp_logs_n(raw: str, *, max_n: int = DEFAULT_MAX_LOGS_N) -> int:
270
+ """
271
+ Парсит и капит параметр ``n`` запроса логов.
272
+
273
+ Бросает ``ValueError`` на некорректный ввод (не число, отрицательное) —
274
+ вызывающая сторона обязана поймать это и вернуть 400, а не 500/креш.
275
+ """
276
+ n = int(raw)
277
+ if n < 0:
278
+ raise ValueError("n не может быть отрицательным")
279
+ return min(n, max_n)
280
+
281
+
282
+ def validate_content_length(header_value: Optional[str], *, max_bytes: int = DEFAULT_MAX_BODY_BYTES) -> int:
283
+ """
284
+ Разбирает заголовок ``Content-Length`` и проверяет лимит тела запроса
285
+ ДО его фактического чтения из сокета (input-hardening/DoS-защита).
286
+
287
+ Бросает ``ValueError`` на некорректное, отрицательное или превышающее
288
+ лимит значение — вызывающая сторона обязана поймать это и вернуть 400
289
+ клиенту, а не 500/креш процесса.
290
+ """
291
+ raw = header_value if header_value not in (None, "") else "0"
292
+ try:
293
+ n = int(raw)
294
+ except (TypeError, ValueError) as exc:
295
+ raise ValueError(f"некорректный Content-Length: {raw!r}") from exc
296
+ if n < 0:
297
+ raise ValueError("Content-Length не может быть отрицательным")
298
+ if n > max_bytes:
299
+ raise ValueError(f"тело запроса превышает лимит {max_bytes} байт")
300
+ return n