sud-cli 0.1.1__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.
Files changed (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,943 @@
1
+ """
2
+ sud_netguard — общий защитный сетевой слой the project (egress guard + per-host breaker + spotlighting).
3
+
4
+ Возможности:
5
+ 1. Egress guard (fail-closed): запросы в loopback/приватные сети/link-local/
6
+ облачную метадату (169.254.169.254) запрещены, кроме явного trust-списка.
7
+ DNS-имена резолвятся и проверяется КАЖДЫЙ адрес (защита от DNS-rebinding).
8
+ 2. Per-host breaker: при 403/429/капче хост уходит в cooldown (по умолчанию 60 с),
9
+ состояние крос-процессное (SQLite), чтобы CLI-запуски не долбили хост лавиной.
10
+ 3. Spotlighting: обёртка недоверенного контента в маркированные теги
11
+ <external_untrusted_data> с экранированием попыток побега из тега.
12
+
13
+ Только stdlib (кроме ленивого import requests в guarded_get).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import http.client
19
+ import ipaddress
20
+ import os
21
+ import re
22
+ import socket
23
+ import sqlite3
24
+ import ssl
25
+ import threading
26
+ import time
27
+ import urllib.request
28
+ from pathlib import Path
29
+ from typing import Any, NamedTuple, Optional
30
+
31
+ NETGUARD_VERSION = "0.3.2"
32
+
33
+
34
+ class EgressBlockedError(RuntimeError):
35
+ """Запрос в запрещённую сеть (fail-closed)."""
36
+
37
+
38
+ class BreakerOpenError(RuntimeError):
39
+ """Хост в cooldown после 4xx/капчи; повтор позже."""
40
+
41
+ def __init__(self, host: str, remaining: float, reason: str = ""):
42
+ self.host = host
43
+ self.remaining = remaining
44
+ self.reason = reason
45
+ super().__init__(
46
+ f"breaker open for {host}: cooldown {remaining:.0f}s remaining"
47
+ + (f" (last reason: {reason})" if reason else "")
48
+ )
49
+
50
+
51
+ class _ValidatedTarget(NamedTuple):
52
+ hostport: str
53
+ scheme: str
54
+ hostname: str
55
+ port: int
56
+ address: str
57
+ authority: str
58
+
59
+
60
+ # --- Egress guard -----------------------------------------------------------
61
+
62
+ # Доверенные внутренние endpoint'ы (host или host:port). Расширяется через env
63
+ # SUD_NETGUARD_TRUSTED="host[:port],host[:port]".
64
+ _TRUSTED_DEFAULT: set[str] = set() # trusted-эндпоинты — только явным env (audit 25.10)
65
+
66
+
67
+ def _trusted() -> set[str]:
68
+ extra = os.environ.get("SUD_NETGUARD_TRUSTED", "")
69
+ out = set(_TRUSTED_DEFAULT)
70
+ for part in extra.split(","):
71
+ part = part.strip().lower()
72
+ if part:
73
+ out.add(part)
74
+ return out
75
+
76
+
77
+ def _is_denied_addr(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
78
+ if addr.is_loopback or addr.is_private or addr.is_link_local:
79
+ return True
80
+ if addr.is_unspecified or addr.is_multicast or addr.is_reserved:
81
+ return True
82
+ # Облачная метадата и CGNAT-диапазоны — не наши цели.
83
+ if addr.version == 4 and str(addr).startswith("169.254.169.254"):
84
+ return True
85
+ if addr.version == 4 and addr in ipaddress.ip_network("100.64.0.0/10"):
86
+ return True
87
+ return False
88
+
89
+
90
+ def _validated_target(url: str, *, resolve: bool = True) -> _ValidatedTarget:
91
+ """Parse, validate every DNS answer, and retain one exact address to pin."""
92
+ from urllib.parse import urlsplit
93
+
94
+ try:
95
+ parts = urlsplit(url)
96
+ scheme = parts.scheme.lower()
97
+ hostname = parts.hostname
98
+ explicit_port = parts.port
99
+ except Exception as exc: # noqa: BLE001
100
+ raise EgressBlockedError(f"unparseable url: {url!r}") from exc
101
+ if scheme not in ("http", "https"):
102
+ raise EgressBlockedError(f"scheme not allowed: {scheme!r} ({url!r})")
103
+ if not hostname or parts.username is not None or parts.password is not None:
104
+ raise EgressBlockedError(f"invalid or credentialed hostname in url: {url!r}")
105
+
106
+ host = hostname.lower().rstrip(".")
107
+ port = explicit_port if explicit_port is not None else (443 if scheme == "https" else 80)
108
+ if not 1 <= port <= 65535:
109
+ raise EgressBlockedError(f"bad port in url: {url!r}")
110
+ hostport = f"{host}:{port}"
111
+ trusted_hosts = _trusted()
112
+ trusted = host in trusted_hosts or hostport in trusted_hosts
113
+
114
+ try:
115
+ literal = ipaddress.ip_address(host)
116
+ except ValueError:
117
+ literal = None
118
+ if literal is not None:
119
+ if _is_denied_addr(literal) and not trusted:
120
+ raise EgressBlockedError(f"egress denied to {hostport} (non-public address)")
121
+ return _ValidatedTarget(hostport, scheme, host, port, str(literal), parts.netloc)
122
+
123
+ if not resolve:
124
+ return _ValidatedTarget(hostport, scheme, host, port, "", parts.netloc)
125
+
126
+ try:
127
+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
128
+ except Exception as exc: # noqa: BLE001
129
+ raise EgressBlockedError(f"DNS failure for {host} (fail-closed)") from exc
130
+ if not infos:
131
+ raise EgressBlockedError(f"DNS returned no addresses for {host} (fail-closed)")
132
+
133
+ addresses: list[str] = []
134
+ for info in infos:
135
+ try:
136
+ family, _socktype, _proto, _canonname, sockaddr = info
137
+ if family not in (socket.AF_INET, socket.AF_INET6) or not sockaddr:
138
+ raise ValueError("unsupported address family or empty sockaddr")
139
+ candidate = sockaddr[0]
140
+ if not isinstance(candidate, str) or "%" in candidate:
141
+ raise ValueError("malformed or scoped address")
142
+ addr = ipaddress.ip_address(candidate)
143
+ if (family == socket.AF_INET and addr.version != 4) or (
144
+ family == socket.AF_INET6 and addr.version != 6
145
+ ):
146
+ raise ValueError("address family does not match answer")
147
+ except Exception as exc: # noqa: BLE001
148
+ raise EgressBlockedError(f"malformed DNS answer for {host}") from exc
149
+ if _is_denied_addr(addr) and not trusted:
150
+ raise EgressBlockedError(f"egress denied: {host} resolves to non-public {addr}")
151
+ addresses.append(str(addr))
152
+
153
+ return _ValidatedTarget(hostport, scheme, host, port, addresses[0], parts.netloc)
154
+
155
+
156
+ def guard_url(url: str, *, resolve: bool = True) -> str:
157
+ """Проверить URL и вернуть нормализованный host[:port]."""
158
+ return _validated_target(url, resolve=resolve).hostport
159
+
160
+
161
+ # --- Per-host breaker (крос-процессный, SQLite) ------------------------------
162
+
163
+ _DEFAULT_DB = "netguard_breaker.sqlite3"
164
+
165
+ # Суффиксы хостов, к которым применяется breaker. Env:
166
+ # SUD_NETGUARD_BREAKER_SUFFIXES=".cian.ru,.avito.ru,sudrf.ru"
167
+ _SUFFIX_DEFAULT = (
168
+ ".cian.ru", "cian.ru", ".avito.ru", "avito.ru",
169
+ "sudrf.ru", "sudact.ru", ".sudact.ru",
170
+ "rosreestr.gov.ru", ".rosreestr.gov.ru",
171
+ "pravo.gov.ru", ".pravo.gov.ru",
172
+ # v2.3.3 (review R7): efrsb_public дёргает breaker_trip для fedresurs, а хоста
173
+ # не было в списке — per-host cooldown молча не работал.
174
+ "bankrot.fedresurs.ru", ".bankrot.fedresurs.ru",
175
+ "fssp.gov.ru", ".fssp.gov.ru", "api.fssp.gov.ru",
176
+ "vsrf.ru", ".vsrf.ru",
177
+ "mos-gorsud.ru", ".mos-gorsud.ru",
178
+ )
179
+
180
+
181
+ def _breaker_db() -> str:
182
+ return os.environ.get("SUD_NETGUARD_DB", _DEFAULT_DB)
183
+
184
+
185
+ def _breaker_suffixes() -> tuple[str, ...]:
186
+ extra = os.environ.get("SUD_NETGUARD_BREAKER_SUFFIXES", "")
187
+ base = list(_SUFFIX_DEFAULT)
188
+ for part in extra.split(","):
189
+ part = part.strip().lower()
190
+ if part and part not in base:
191
+ base.append(part)
192
+ return tuple(base)
193
+
194
+
195
+ def _host_key(hostport_or_host: str) -> str:
196
+ """Выделить hostname из 'host', 'host:port' ИЛИ полного URL."""
197
+ value = (hostport_or_host or "").strip()
198
+ if "://" in value:
199
+ from urllib.parse import urlsplit
200
+
201
+ netloc = urlsplit(value).netloc
202
+ value = netloc or value
203
+ host = value.split("/")[0].split(":")[0].lower().rstrip(".")
204
+ return host
205
+
206
+
207
+ def _breaker_conn() -> sqlite3.Connection:
208
+ path = _breaker_db()
209
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
210
+ conn = sqlite3.connect(path, timeout=5.0)
211
+ conn.execute(
212
+ "CREATE TABLE IF NOT EXISTS breaker_state ("
213
+ " host TEXT PRIMARY KEY, open_until REAL NOT NULL, reason TEXT)"
214
+ )
215
+ conn.execute(
216
+ "CREATE TABLE IF NOT EXISTS breaker_soft ("
217
+ " host TEXT PRIMARY KEY, failures INTEGER NOT NULL, reason TEXT)"
218
+ )
219
+ return conn
220
+
221
+
222
+ def _applies_to(host: str) -> bool:
223
+ host = _host_key(host)
224
+ for suf in _breaker_suffixes():
225
+ bare = suf.lstrip(".")
226
+ if host == bare:
227
+ return True
228
+ if host.endswith("." + bare) or host.endswith("--" + bare):
229
+ return True
230
+ return False
231
+
232
+
233
+ def breaker_remaining(host: str) -> float:
234
+ """Сколько секунд cooldown осталось у хоста (0 = можно ходить)."""
235
+ if not _applies_to(host):
236
+ return 0.0
237
+ try:
238
+ with _breaker_conn() as conn:
239
+ row = conn.execute(
240
+ "SELECT open_until, reason FROM breaker_state WHERE host = ?",
241
+ (_host_key(host),),
242
+ ).fetchone()
243
+ except sqlite3.Error:
244
+ return 0.0 # state-файл недоступен: не блокируем работу (breaker — оптимизация)
245
+ if not row:
246
+ return 0.0
247
+ remaining = float(row[0]) - time.time()
248
+ return remaining if remaining > 0 else 0.0
249
+
250
+
251
+ def breaker_trip(host: str, *, seconds: float = 60.0, reason: str = "") -> None:
252
+ """Включить cooldown для хоста (жёсткий блок: 403/429/капча)."""
253
+ if not _applies_to(host):
254
+ return
255
+ try:
256
+ with _breaker_conn() as conn:
257
+ conn.execute(
258
+ "INSERT INTO breaker_state (host, open_until, reason) VALUES (?, ?, ?) "
259
+ "ON CONFLICT(host) DO UPDATE SET open_until = excluded.open_until, "
260
+ "reason = excluded.reason",
261
+ (_host_key(host), time.time() + seconds, reason or "hard_block"),
262
+ )
263
+ except sqlite3.Error:
264
+ pass
265
+
266
+
267
+ def breaker_soft_fail(host: str, *, threshold: int = 3,
268
+ cooldown: float = 60.0, reason: str = "") -> None:
269
+ """Мягкий сигнал (challenge-маркер и т.п.): триггер после N повторов.
270
+
271
+ Важно: trip выполняется ПОСЛЕ закрытия соединения состояния, иначе
272
+ вложенное соединение к тому же файлу ловит database-is-locked.
273
+ """
274
+ if not _applies_to(host):
275
+ return
276
+ key = _host_key(host)
277
+ n = 1
278
+ try:
279
+ with _breaker_conn() as conn:
280
+ row = conn.execute(
281
+ "SELECT failures FROM breaker_soft WHERE host = ?", (key,)
282
+ ).fetchone()
283
+ n = (int(row[0]) + 1) if row else 1
284
+ if n >= threshold:
285
+ conn.execute("DELETE FROM breaker_soft WHERE host = ?", (key,))
286
+ else:
287
+ conn.execute(
288
+ "INSERT INTO breaker_soft (host, failures, reason) VALUES (?, ?, ?) "
289
+ "ON CONFLICT(host) DO UPDATE SET failures = excluded.failures",
290
+ (key, n, reason or "soft"),
291
+ )
292
+ except sqlite3.Error:
293
+ return
294
+ if n >= threshold:
295
+ breaker_trip(key, seconds=cooldown, reason=reason or f"soft_{n}")
296
+
297
+
298
+ def breaker_success(host: str) -> None:
299
+ key = _host_key(host)
300
+ try:
301
+ with _breaker_conn() as conn:
302
+ conn.execute("DELETE FROM breaker_soft WHERE host = ?", (key,))
303
+ except sqlite3.Error:
304
+ pass
305
+
306
+
307
+ def _check_target(target: _ValidatedTarget) -> None:
308
+ remaining = breaker_remaining(target.hostport)
309
+ if remaining > 0:
310
+ raise BreakerOpenError(
311
+ _host_key(target.hostport), remaining, reason=_last_reason(target.hostport)
312
+ )
313
+
314
+
315
+ def check_before_fetch(url: str) -> str:
316
+ """Полная пред-проверка: egress-guard + breaker. Возвращает hostport."""
317
+ target = _validated_target(url)
318
+ _check_target(target)
319
+ return target.hostport
320
+
321
+
322
+ def _last_reason(host: str) -> str:
323
+ try:
324
+ with _breaker_conn() as conn:
325
+ row = conn.execute(
326
+ "SELECT reason FROM breaker_state WHERE host = ?", (_host_key(host),)
327
+ ).fetchone()
328
+ return str(row[0]) if row else ""
329
+ except sqlite3.Error:
330
+ return ""
331
+
332
+
333
+ # --- Транспортные обёртки ----------------------------------------------------
334
+
335
+ _TRIP_STATUSES = {403, 429}
336
+
337
+
338
+ class _PinnedHTTPConnection(http.client.HTTPConnection):
339
+ def __init__(self, host: str, *, pinned_address: str, **kwargs: Any):
340
+ self._pinned_address = pinned_address
341
+ super().__init__(host, **kwargs)
342
+
343
+ def connect(self) -> None:
344
+ if self._tunnel_host:
345
+ raise EgressBlockedError("HTTP proxy tunnels are unsupported")
346
+ self.sock = socket.create_connection(
347
+ (self._pinned_address, self.port), self.timeout, self.source_address
348
+ )
349
+
350
+
351
+ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
352
+ def __init__(self, host: str, *, pinned_address: str, tls_hostname: str,
353
+ **kwargs: Any):
354
+ self._pinned_address = pinned_address
355
+ self._tls_hostname = tls_hostname
356
+ super().__init__(host, **kwargs)
357
+
358
+ def connect(self) -> None:
359
+ if self._tunnel_host:
360
+ raise EgressBlockedError("HTTPS proxy tunnels are unsupported")
361
+ raw_sock = socket.create_connection(
362
+ (self._pinned_address, self.port), self.timeout, self.source_address
363
+ )
364
+ # HTTPSConnection's verified context is retained; only the TCP target is pinned.
365
+ self.sock = self._context.wrap_socket(
366
+ raw_sock, server_hostname=self._tls_hostname
367
+ )
368
+
369
+
370
+ def _assert_request_matches(req: Any, target: _ValidatedTarget) -> None:
371
+ from urllib.parse import urlsplit
372
+
373
+ if req.has_proxy():
374
+ raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
375
+ try:
376
+ parts = urlsplit(req.full_url)
377
+ request_port = parts.port if parts.port is not None else (
378
+ 443 if parts.scheme.lower() == "https" else 80
379
+ )
380
+ except Exception as exc: # noqa: BLE001
381
+ raise EgressBlockedError("malformed request reached pinned transport") from exc
382
+ request_host = (parts.hostname or "").lower().rstrip(".")
383
+ if (parts.scheme.lower() != target.scheme or request_host != target.hostname
384
+ or request_port != target.port):
385
+ raise EgressBlockedError("request target changed after DNS validation")
386
+
387
+
388
+ class _PinnedHTTPHandler(urllib.request.HTTPHandler):
389
+ def __init__(self, target: _ValidatedTarget):
390
+ super().__init__()
391
+ self._target = target
392
+
393
+ def http_open(self, req: Any):
394
+ _assert_request_matches(req, self._target)
395
+ factory = lambda host, **kwargs: _PinnedHTTPConnection(
396
+ host, pinned_address=self._target.address, **kwargs
397
+ )
398
+ return self.do_open(factory, req)
399
+
400
+
401
+ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
402
+ def __init__(self, target: _ValidatedTarget):
403
+ self._target = target
404
+ context = ssl.create_default_context()
405
+ super().__init__(context=context)
406
+
407
+ def https_open(self, req: Any):
408
+ _assert_request_matches(req, self._target)
409
+ factory = lambda host, **kwargs: _PinnedHTTPSConnection(
410
+ host,
411
+ pinned_address=self._target.address,
412
+ tls_hostname=self._target.hostname,
413
+ **kwargs,
414
+ )
415
+ return self.do_open(factory, req, context=self._context)
416
+
417
+
418
+ class _RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
419
+ def redirect_request(self, req: Any, fp: Any, code: int, msg: str,
420
+ headers: Any, newurl: str):
421
+ raise EgressBlockedError("redirects are disabled by the pinned transport")
422
+
423
+
424
+ def _origin_headers(headers: Optional[dict], authority: str) -> dict:
425
+ result = {key: value for key, value in (headers or {}).items()
426
+ if key.lower() != "host"}
427
+ result["Host"] = authority
428
+ return result
429
+
430
+
431
+ def _pinned_open(target: _ValidatedTarget, url: str, timeout: float,
432
+ headers: Optional[dict]):
433
+ req = urllib.request.Request(url, headers=_origin_headers(headers, target.authority))
434
+ opener = urllib.request.build_opener(
435
+ urllib.request.ProxyHandler({}),
436
+ _PinnedHTTPHandler(target),
437
+ _PinnedHTTPSHandler(target),
438
+ _RejectRedirectHandler(),
439
+ )
440
+ return opener.open(req, timeout=timeout)
441
+
442
+
443
+ def _requests_url_matches(url: str, target: _ValidatedTarget) -> None:
444
+ from urllib.parse import urlsplit
445
+
446
+ try:
447
+ parts = urlsplit(url)
448
+ port = parts.port if parts.port is not None else (
449
+ 443 if parts.scheme.lower() == "https" else 80
450
+ )
451
+ except Exception as exc: # noqa: BLE001
452
+ raise EgressBlockedError("malformed request reached pinned requests transport") from exc
453
+ if ((parts.scheme or "").lower() != target.scheme
454
+ or (parts.hostname or "").lower().rstrip(".") != target.hostname
455
+ or port != target.port):
456
+ raise EgressBlockedError("request target changed after DNS validation")
457
+
458
+
459
+ def guarded_get(url: str, *, timeout: float = 30.0, headers: Optional[dict] = None,
460
+ stream: bool = False, session: Any = None,
461
+ challenge_marker: str = "") -> Any:
462
+ """GET with a requests-compatible response over a DNS-pinned connection.
463
+
464
+ The connection pool is keyed by the validated address, while the HTTP Host,
465
+ TLS SNI, and certificate hostname remain the original URL hostname. Redirects
466
+ are returned to the caller and never followed automatically.
467
+ """
468
+ try:
469
+ import requests
470
+ from requests.adapters import HTTPAdapter
471
+ from requests.cookies import extract_cookies_to_jar
472
+ from requests.sessions import dispatch_hook
473
+ except ImportError as exc: # noqa: BLE001
474
+ raise EgressBlockedError("requests is required for guarded_get") from exc
475
+
476
+ target = _validated_target(url)
477
+ _check_target(target)
478
+
479
+ owns_session = session is None
480
+ session_close_attempted = False
481
+ created_adapters = []
482
+ if owns_session:
483
+ session = requests.Session.__new__(requests.Session)
484
+ original_mount = session.mount
485
+
486
+ def track_mount(prefix, adapter):
487
+ if all(existing is not adapter for existing in created_adapters):
488
+ created_adapters.append(adapter)
489
+ return original_mount(prefix, adapter)
490
+
491
+ session.mount = track_mount
492
+ try:
493
+ requests.Session.__init__(session)
494
+ except BaseException:
495
+ for adapter in created_adapters:
496
+ try:
497
+ adapter.close()
498
+ except BaseException:
499
+ pass
500
+ raise
501
+ finally:
502
+ session.__dict__.pop("mount", None)
503
+ elif type(session) is not requests.Session:
504
+ raise EgressBlockedError("custom requests sessions are unsupported by the pinned transport")
505
+
506
+ def close_owned_session():
507
+ nonlocal session_close_attempted
508
+ if not owns_session or session_close_attempted:
509
+ return
510
+ session_close_attempted = True
511
+
512
+ attempted_adapters = []
513
+ wrapped_adapters = []
514
+ for owned_adapter in created_adapters:
515
+ original_closer = owned_adapter.close
516
+ had_instance_closer = "close" in owned_adapter.__dict__
517
+ previous_instance_closer = owned_adapter.__dict__.get("close")
518
+
519
+ def track_close(adapter=owned_adapter, closer=original_closer):
520
+ if any(attempted is adapter for attempted in attempted_adapters):
521
+ return None
522
+ attempted_adapters.append(adapter)
523
+ return closer()
524
+
525
+ owned_adapter.close = track_close
526
+ wrapped_adapters.append((
527
+ owned_adapter, original_closer,
528
+ had_instance_closer, previous_instance_closer,
529
+ ))
530
+
531
+ session_error = None
532
+ cleanup_error = None
533
+ try:
534
+ session.close()
535
+ except BaseException as exc:
536
+ session_error = exc
537
+ finally:
538
+ for owned_adapter, _, had_instance_closer, previous_instance_closer in wrapped_adapters:
539
+ try:
540
+ if had_instance_closer:
541
+ owned_adapter.close = previous_instance_closer
542
+ else:
543
+ del owned_adapter.close
544
+ except BaseException as exc:
545
+ if cleanup_error is None:
546
+ cleanup_error = exc
547
+
548
+ for owned_adapter, original_closer, _, _ in wrapped_adapters:
549
+ if any(attempted is owned_adapter for attempted in attempted_adapters):
550
+ continue
551
+ try:
552
+ original_closer()
553
+ except BaseException as exc:
554
+ if cleanup_error is None:
555
+ cleanup_error = exc
556
+
557
+ if session_error is not None:
558
+ raise session_error
559
+ if cleanup_error is not None:
560
+ raise cleanup_error
561
+
562
+ # We intentionally do not use any session adapter: only its standard request
563
+ # preparation is reused, so cookies/auth/headers survive without granting a
564
+ # custom adapter control of the socket destination.
565
+ try:
566
+ if any(type(adapter) is not HTTPAdapter for adapter in session.adapters.values()):
567
+ raise EgressBlockedError("custom requests adapters are unsupported by the pinned transport")
568
+
569
+ request = requests.Request("GET", url, headers=headers)
570
+ prepared = session.prepare_request(request)
571
+ _requests_url_matches(prepared.url, target)
572
+ prepared.headers["Host"] = target.authority
573
+
574
+ settings = session.merge_environment_settings(
575
+ prepared.url, {}, stream=stream, verify=session.verify, cert=session.cert
576
+ )
577
+ if settings.get("proxies"):
578
+ raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
579
+ except BaseException:
580
+ if owns_session:
581
+ try:
582
+ close_owned_session()
583
+ except BaseException:
584
+ pass
585
+ raise
586
+
587
+ class _PinnedRequestsAdapter(HTTPAdapter):
588
+ def __init__(self):
589
+ try:
590
+ super().__init__()
591
+ except BaseException:
592
+ try:
593
+ self.close()
594
+ except BaseException:
595
+ pass
596
+ raise
597
+
598
+ def _pool_for(self, request_or_url: Any, verify: Any, cert: Any):
599
+ request_url = getattr(request_or_url, "url", request_or_url)
600
+ _requests_url_matches(request_url, target)
601
+ if not hasattr(self, "build_connection_pool_key_attributes"):
602
+ pool_kwargs = {}
603
+ if target.scheme == "https":
604
+ pool_kwargs.update(
605
+ assert_hostname=target.hostname,
606
+ server_hostname=target.hostname,
607
+ )
608
+ return self.poolmanager.connection_from_host(
609
+ host=target.address,
610
+ port=target.port,
611
+ scheme=target.scheme,
612
+ pool_kwargs=pool_kwargs,
613
+ )
614
+
615
+ request_for_key = request_or_url
616
+ if not hasattr(request_for_key, "url"):
617
+ request_for_key = type("PreparedURL", (), {"url": request_url})()
618
+ host_params, pool_kwargs = self.build_connection_pool_key_attributes(
619
+ request_for_key, verify, cert
620
+ )
621
+ host_params = dict(host_params)
622
+ pool_kwargs = dict(pool_kwargs)
623
+ host_params["host"] = target.address
624
+ if target.scheme == "https":
625
+ # urllib3 connects to the pool host (the IP), but uses these
626
+ # explicit names for SNI and certificate hostname verification.
627
+ pool_kwargs["assert_hostname"] = target.hostname
628
+ pool_kwargs["server_hostname"] = target.hostname
629
+ return self.poolmanager.connection_from_host(
630
+ **host_params, pool_kwargs=pool_kwargs
631
+ )
632
+
633
+ def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
634
+ if proxies:
635
+ raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
636
+ return self._pool_for(request, verify, cert)
637
+
638
+ def get_connection(self, url, proxies=None):
639
+ if proxies:
640
+ raise EgressBlockedError("proxy requests are unsupported by the pinned transport")
641
+ return self._pool_for(url, self.config.get("verify", True), None)
642
+
643
+ adapter = None
644
+ response = None
645
+ response_close_installed = False
646
+ owned_responses = []
647
+ owned_raws = []
648
+ cleaned_raws = []
649
+
650
+ def remember_raw(raw):
651
+ if raw is not None and all(existing is not raw for existing in owned_raws):
652
+ owned_raws.append(raw)
653
+
654
+ def remember_response_raw(candidate):
655
+ try:
656
+ raw = getattr(candidate, "raw", None)
657
+ except BaseException:
658
+ return
659
+ remember_raw(raw)
660
+
661
+ def cleanup_raw_resource(raw):
662
+ """Best-effort raw cleanup, returning its first introspection/close error."""
663
+ if raw is None:
664
+ return None
665
+ if any(cleaned is raw for cleaned in cleaned_raws):
666
+ return None
667
+ cleaned_raws.append(raw)
668
+ try:
669
+ raw_closer = getattr(raw, "close", None)
670
+ except BaseException as exc:
671
+ return exc
672
+ if not callable(raw_closer):
673
+ return None
674
+ first_error = None
675
+ try:
676
+ raw_closed = bool(getattr(raw, "closed", False))
677
+ except BaseException as exc:
678
+ first_error = exc
679
+ raw_closed = False
680
+ if not raw_closed:
681
+ try:
682
+ raw_closer()
683
+ except BaseException as exc:
684
+ if first_error is None:
685
+ first_error = exc
686
+ return first_error
687
+
688
+ def cleanup_raw_response(owned_response):
689
+ try:
690
+ raw = getattr(owned_response, "raw", None)
691
+ except BaseException as exc:
692
+ return exc
693
+ return cleanup_raw_resource(raw)
694
+
695
+ def remember_response(candidate):
696
+ if candidate is not None and all(
697
+ existing is not candidate for existing in owned_responses
698
+ ):
699
+ owned_responses.append(candidate)
700
+
701
+ def track_response_hook(hook):
702
+ def tracked_hook(hook_response, **kwargs):
703
+ remember_response(hook_response)
704
+ remember_response_raw(hook_response)
705
+ try:
706
+ result = hook(hook_response, **kwargs)
707
+ finally:
708
+ remember_response_raw(hook_response)
709
+ if result is not None:
710
+ remember_response(result)
711
+ remember_response_raw(result)
712
+ return result
713
+
714
+ return tracked_hook
715
+
716
+ try:
717
+ adapter = _PinnedRequestsAdapter()
718
+ response = adapter.send(
719
+ prepared,
720
+ stream=stream,
721
+ timeout=timeout,
722
+ verify=settings["verify"],
723
+ cert=settings["cert"],
724
+ proxies={},
725
+ )
726
+ remember_response(response)
727
+ remember_response_raw(response)
728
+ extract_cookies_to_jar(session.cookies, prepared, response.raw)
729
+
730
+ # Apply host cooldowns to the actual transport response before user hooks can
731
+ # replace it or raise. Inspecting a requested challenge marker may buffer a
732
+ # streaming body, but requests keeps that content available to the caller.
733
+ if response.status_code in _TRIP_STATUSES:
734
+ breaker_trip(url, reason=f"http_{response.status_code}")
735
+ elif challenge_marker and challenge_marker.casefold() in response.text.casefold():
736
+ breaker_soft_fail(target.hostport, reason="challenge_marker")
737
+ else:
738
+ breaker_success(target.hostport)
739
+
740
+ hooks = prepared.hooks
741
+ response_hooks = hooks.get("response") if hooks else None
742
+ if response_hooks:
743
+ if hasattr(response_hooks, "__call__"):
744
+ response_hooks = [response_hooks]
745
+ hooks = dict(hooks)
746
+ hooks["response"] = [
747
+ track_response_hook(hook) for hook in response_hooks
748
+ ]
749
+ response = dispatch_hook(
750
+ "response", hooks, response,
751
+ stream=stream, timeout=timeout, verify=settings["verify"],
752
+ cert=settings["cert"], proxies={},
753
+ )
754
+ remember_response(response)
755
+ remember_response_raw(response)
756
+ if not stream:
757
+ response.content
758
+ response.history = []
759
+ response._netguard_adapter = adapter
760
+
761
+ # Retain the original close methods before wrapping the final response.
762
+ # Every response stays usable until the caller closes this final response.
763
+ close_order = [response] + [
764
+ owned for owned in owned_responses if owned is not response
765
+ ]
766
+ response_closers = [owned.close for owned in close_order]
767
+ response_closed = False
768
+ response_close_lock = threading.Lock()
769
+ response_close_complete = threading.Event()
770
+ response_close_error = None
771
+
772
+ def close_response():
773
+ nonlocal response_closed, response_close_error
774
+ is_cleanup_owner = False
775
+ wait_for_cleanup = False
776
+ with response_close_lock:
777
+ if response_closed:
778
+ wait_for_cleanup = not response_close_complete.is_set()
779
+ else:
780
+ response_closed = True
781
+ is_cleanup_owner = True
782
+ if not is_cleanup_owner:
783
+ # In-flight waiters observe the first error; later closes are idempotent.
784
+ if wait_for_cleanup:
785
+ response_close_complete.wait()
786
+ with response_close_lock:
787
+ completed_error = response_close_error
788
+ if completed_error is not None:
789
+ raise completed_error
790
+ return
791
+
792
+ first_error = None
793
+ try:
794
+ for owned_response, response_closer in zip(close_order, response_closers):
795
+ is_base_close = False
796
+ try:
797
+ is_base_close = (
798
+ getattr(response_closer, "__func__", None)
799
+ is requests.Response.close
800
+ )
801
+ except BaseException:
802
+ pass
803
+ skip_base_close = False
804
+ if is_base_close:
805
+ try:
806
+ response_raw = getattr(owned_response, "raw", None)
807
+ except BaseException:
808
+ response_raw = None
809
+ skip_base_close = response_raw is not None and any(
810
+ cleaned is response_raw for cleaned in cleaned_raws
811
+ )
812
+ if not skip_base_close:
813
+ try:
814
+ response_closer()
815
+ except BaseException as exc:
816
+ if first_error is None:
817
+ first_error = exc
818
+ raw_error = cleanup_raw_response(owned_response)
819
+ if first_error is None and raw_error is not None:
820
+ first_error = raw_error
821
+ for raw in owned_raws:
822
+ raw_error = cleanup_raw_resource(raw)
823
+ if first_error is None and raw_error is not None:
824
+ first_error = raw_error
825
+ try:
826
+ adapter.close()
827
+ except BaseException as exc:
828
+ if first_error is None:
829
+ first_error = exc
830
+ except BaseException as exc:
831
+ if first_error is None:
832
+ first_error = exc
833
+ finally:
834
+ with response_close_lock:
835
+ response_close_error = first_error
836
+ response_close_complete.set()
837
+ if first_error is not None:
838
+ raise first_error
839
+
840
+ response.close = close_response
841
+ response_close_installed = True
842
+ if owns_session:
843
+ close_owned_session()
844
+ return response
845
+ except BaseException:
846
+ if response_close_installed:
847
+ try:
848
+ response.close()
849
+ except BaseException:
850
+ pass
851
+ else:
852
+ for owned_response in owned_responses:
853
+ try:
854
+ owned_response.close()
855
+ except BaseException:
856
+ pass
857
+ cleanup_raw_response(owned_response)
858
+ for raw in owned_raws:
859
+ cleanup_raw_resource(raw)
860
+ if adapter is not None:
861
+ try:
862
+ adapter.close()
863
+ except BaseException:
864
+ pass
865
+ if owns_session and not session_close_attempted:
866
+ try:
867
+ close_owned_session()
868
+ except BaseException:
869
+ pass
870
+ raise
871
+
872
+
873
+ def guarded_urlopen(url: str, *, timeout: float = 30.0,
874
+ headers: Optional[dict] = None) -> bytes:
875
+ """Pinned urllib fetch returning body bytes; redirects fail closed."""
876
+ target = _validated_target(url)
877
+ _check_target(target)
878
+ try:
879
+ with _pinned_open(target, url, timeout, headers) as resp:
880
+ body = resp.read()
881
+ except urllib.error.HTTPError as exc:
882
+ if exc.code in _TRIP_STATUSES:
883
+ breaker_trip(url, reason=f"http_{exc.code}")
884
+ raise
885
+ breaker_success(target.hostport)
886
+ return body
887
+
888
+
889
+ def guarded_open(url: str, *, timeout: float = 30.0, headers: Optional[dict] = None):
890
+ """Pinned urllib fetch returning a streaming response; redirects fail closed."""
891
+ target = _validated_target(url)
892
+ _check_target(target)
893
+ try:
894
+ return _pinned_open(target, url, timeout, headers)
895
+ except urllib.error.HTTPError as exc:
896
+ if exc.code in _TRIP_STATUSES:
897
+ breaker_trip(url, reason=f"http_{exc.code}")
898
+ raise
899
+
900
+
901
+ # --- Spotlighting -------------------------------------------------------------
902
+
903
+ _MARKER_OPEN = "<external_untrusted_data source='{source}' type='{data_type}'>"
904
+ _MARKER_CLOSE = "</external_untrusted_data>"
905
+ _ESCAPE_RE = re.compile(r"</\s*external_untrusted_data\s*>", re.IGNORECASE)
906
+
907
+
908
+ def spotlight(raw: str, *, source: str = "web", data_type: str = "text") -> str:
909
+ """Обернуть недоверенный контент в маркированные теги.
910
+
911
+ Все попытки преждевременно закрыть тег экранируются, чтобы контент
912
+ не мог «выйти» из песочницы разметки и притвориться инструкцией.
913
+ """
914
+ sanitized = _ESCAPE_RE.sub("&lt;/external_untrusted_data&gt;", raw or "")
915
+ return (
916
+ _MARKER_OPEN.format(source=source, data_type=data_type)
917
+ + sanitized
918
+ + _MARKER_CLOSE
919
+ )
920
+
921
+
922
+ _MARKED_PREFIX_RE = re.compile(
923
+ r"^<external_untrusted_data source='[^']*' type='[^']*'>"
924
+ )
925
+
926
+
927
+ def is_spotlighted(text: str) -> bool:
928
+ """True, если текст уже обёрнут в spotlight-маркер."""
929
+ return bool(text) and bool(_MARKED_PREFIX_RE.match(text.lstrip()))
930
+
931
+
932
+ def strip_spotlight(text: str) -> str:
933
+ """Снять spotlight-маркеры (для human-facing рендера). Безопасно на чистом тексте."""
934
+ if not text:
935
+ return text
936
+ body = text
937
+ lead = ""
938
+ while body and body[0].isspace():
939
+ lead += body[0]
940
+ body = body[1:]
941
+ body = _MARKED_PREFIX_RE.sub("", body, count=1)
942
+ body = body.replace(_MARKER_CLOSE, "")
943
+ return lead + body