UGOS 1.0.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.
Files changed (67) hide show
  1. ugos/__about__.py +6 -0
  2. ugos/__init__.py +92 -0
  3. ugos/_auth.py +293 -0
  4. ugos/_client_base.py +52 -0
  5. ugos/_client_calls.py +200 -0
  6. ugos/_client_helpers.py +217 -0
  7. ugos/_client_lifecycle.py +144 -0
  8. ugos/_client_resources.py +192 -0
  9. ugos/_client_transfer.py +501 -0
  10. ugos/_codec.py +528 -0
  11. ugos/_config.py +183 -0
  12. ugos/_data/__init__.py +1 -0
  13. ugos/_data/action_contracts.json +325 -0
  14. ugos/_data/catalog.json +1256 -0
  15. ugos/_dynamic.py +221 -0
  16. ugos/_encoding.py +159 -0
  17. ugos/_pagination.py +158 -0
  18. ugos/_redaction.py +158 -0
  19. ugos/_transport.py +446 -0
  20. ugos/catalog.py +184 -0
  21. ugos/client.py +113 -0
  22. ugos/exceptions.py +137 -0
  23. ugos/models/__init__.py +37 -0
  24. ugos/models/common.py +87 -0
  25. ugos/models/entities.py +124 -0
  26. ugos/models/users.py +43 -0
  27. ugos/py.typed +0 -0
  28. ugos/resources/__init__.py +55 -0
  29. ugos/resources/administrators.py +71 -0
  30. ugos/resources/application.py +107 -0
  31. ugos/resources/base.py +38 -0
  32. ugos/resources/certificates.py +62 -0
  33. ugos/resources/contentrules.py +98 -0
  34. ugos/resources/contenttypes.py +9 -0
  35. ugos/resources/fwrules.py +103 -0
  36. ugos/resources/generic.py +283 -0
  37. ugos/resources/generic_controllers.py +169 -0
  38. ugos/resources/groups.py +94 -0
  39. ugos/resources/icap.py +132 -0
  40. ugos/resources/idps.py +125 -0
  41. ugos/resources/interfaces.py +40 -0
  42. ugos/resources/l7.py +110 -0
  43. ugos/resources/licensing.py +39 -0
  44. ugos/resources/mailsecurity.py +101 -0
  45. ugos/resources/networks.py +82 -0
  46. ugos/resources/netzones.py +78 -0
  47. ugos/resources/nlist.py +72 -0
  48. ugos/resources/overridedomains.py +47 -0
  49. ugos/resources/overrides.py +109 -0
  50. ugos/resources/overrides_content.py +254 -0
  51. ugos/resources/overrides_content_a.py +187 -0
  52. ugos/resources/overrides_lists.py +294 -0
  53. ugos/resources/overrides_misc.py +342 -0
  54. ugos/resources/overrides_network.py +291 -0
  55. ugos/resources/pcap.py +64 -0
  56. ugos/resources/registry.py +55 -0
  57. ugos/resources/settings.py +72 -0
  58. ugos/resources/sysinfo.py +39 -0
  59. ugos/resources/twofa.py +62 -0
  60. ugos/resources/urlcategory.py +69 -0
  61. ugos/resources/urlfiltering.py +23 -0
  62. ugos/resources/urllists.py +9 -0
  63. ugos/resources/users.py +74 -0
  64. ugos-1.0.0.dist-info/METADATA +139 -0
  65. ugos-1.0.0.dist-info/RECORD +67 -0
  66. ugos-1.0.0.dist-info/WHEEL +4 -0
  67. ugos-1.0.0.dist-info/licenses/LICENSE +16 -0
ugos/_dynamic.py ADDED
@@ -0,0 +1,221 @@
1
+ """Dynamic access to every controller action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from collections.abc import Iterator, Mapping
7
+ from typing import Any, cast
8
+
9
+ from .exceptions import UGOSError
10
+
11
+
12
+ class ActionCaller:
13
+ """Callable for one exact ``controller/action`` pair."""
14
+
15
+ def __init__(
16
+ self,
17
+ invoker: Any,
18
+ controller: str,
19
+ action: str,
20
+ *,
21
+ strict: bool | None = None,
22
+ ) -> None:
23
+ self._invoker = invoker
24
+ self.controller = controller
25
+ self.action = action
26
+ self._strict = strict
27
+
28
+ def __call__(
29
+ self,
30
+ payload: Mapping[str, Any] | None = None,
31
+ /,
32
+ *,
33
+ json_fields: Any = (),
34
+ method: str | None = None,
35
+ expect: str | None = None,
36
+ idempotent: bool | None = None,
37
+ timeout: Any = None,
38
+ strict: bool | None = None,
39
+ **params: Any,
40
+ ) -> Any:
41
+ kwargs: dict[str, Any] = {
42
+ "payload": payload,
43
+ "json_fields": json_fields,
44
+ "method": method,
45
+ "expect": expect,
46
+ "idempotent": idempotent,
47
+ "strict": self._strict if strict is None else strict,
48
+ **params,
49
+ }
50
+ if timeout is not None:
51
+ kwargs["timeout"] = timeout
52
+ return self._invoker.call(self.controller, self.action, **kwargs)
53
+
54
+ def download(self, **kwargs: Any) -> Any:
55
+ kwargs.setdefault("strict", self._strict)
56
+ return self._invoker.download(self.controller, self.action, **kwargs)
57
+
58
+ def upload(self, **kwargs: Any) -> Any:
59
+ kwargs.setdefault("strict", self._strict)
60
+ return self._invoker.upload(self.controller, self.action, **kwargs)
61
+
62
+ def iter_all(self, **kwargs: Any) -> Iterator[Any]:
63
+ kwargs.setdefault("strict", self._strict)
64
+ result: Iterator[Any] = self._invoker.iter_all(self.controller, self.action, **kwargs)
65
+ return result
66
+
67
+ def __repr__(self) -> str:
68
+ return f"ActionCaller({self.controller!r}, {self.action!r})"
69
+
70
+
71
+ class ActionNamespace:
72
+ """Exact action names for a controller."""
73
+
74
+ def __init__(
75
+ self,
76
+ invoker: Any,
77
+ controller: str,
78
+ *,
79
+ strict: bool | None = None,
80
+ ) -> None:
81
+ self._invoker = invoker
82
+ self._controller = controller
83
+ self._strict = strict
84
+
85
+ def __getattr__(self, name: str) -> ActionCaller:
86
+ if name.startswith("_"):
87
+ raise AttributeError(name)
88
+ return ActionCaller(
89
+ self._invoker,
90
+ self._controller,
91
+ name,
92
+ strict=self._strict,
93
+ )
94
+
95
+ def __getitem__(self, name: str) -> ActionCaller:
96
+ return ActionCaller(
97
+ self._invoker,
98
+ self._controller,
99
+ name,
100
+ strict=self._strict,
101
+ )
102
+
103
+ def __repr__(self) -> str:
104
+ return f"ActionNamespace({self._controller!r})"
105
+
106
+
107
+ class ControllerProxy:
108
+ """Generic proxy for a controller.
109
+
110
+ Typed resources inherit from this class, so ``users.list()`` and
111
+ ``users.UsersList()`` can coexist.
112
+ """
113
+
114
+ controller: str
115
+
116
+ def __init__(
117
+ self,
118
+ invoker: Any,
119
+ controller: str | None = None,
120
+ *,
121
+ strict: bool | None = None,
122
+ ) -> None:
123
+ self._invoker = invoker
124
+ if controller is not None:
125
+ self.controller = controller
126
+ self._strict = strict
127
+
128
+ def call(
129
+ self,
130
+ action: str,
131
+ /,
132
+ *,
133
+ payload: Mapping[str, Any] | None = None,
134
+ json_fields: Any = (),
135
+ method: str | None = None,
136
+ expect: str | None = None,
137
+ idempotent: bool | None = None,
138
+ timeout: Any = None,
139
+ strict: bool | None = None,
140
+ **params: Any,
141
+ ) -> Any:
142
+ kwargs: dict[str, Any] = {
143
+ "payload": payload,
144
+ "json_fields": json_fields,
145
+ "method": method,
146
+ "expect": expect,
147
+ "idempotent": idempotent,
148
+ "strict": self._strict if strict is None else strict,
149
+ **params,
150
+ }
151
+ if timeout is not None:
152
+ kwargs["timeout"] = timeout
153
+ return self._invoker.call(self.controller, action, **kwargs)
154
+
155
+ def download(self, action: str, /, **kwargs: Any) -> Any:
156
+ kwargs.setdefault("strict", self._strict)
157
+ return self._invoker.download(self.controller, action, **kwargs)
158
+
159
+ def upload(self, action: str, /, **kwargs: Any) -> Any:
160
+ kwargs.setdefault("strict", self._strict)
161
+ return self._invoker.upload(self.controller, action, **kwargs)
162
+
163
+ def iter_all(self, action: str, /, **kwargs: Any) -> Iterator[Any]:
164
+ kwargs.setdefault("strict", self._strict)
165
+ result: Iterator[Any] = self._invoker.iter_all(self.controller, action, **kwargs)
166
+ return result
167
+
168
+ @property
169
+ def actions(self) -> ActionNamespace:
170
+ return ActionNamespace(self._invoker, self.controller, strict=self._strict)
171
+
172
+ @staticmethod
173
+ def _known_actions(invoker: Any, controller: str) -> frozenset[str] | None:
174
+ try:
175
+ return invoker.available_actions(controller)
176
+ except UGOSError:
177
+ return None
178
+
179
+ @staticmethod
180
+ def _is_reserved(name: str) -> bool:
181
+ return name.startswith("_") or name == "controller"
182
+
183
+ @staticmethod
184
+ def _missing_context(invoker: Any, controller: str | None) -> bool:
185
+ return invoker is None or controller is None
186
+
187
+ def _raise_if_unknown(self, name: str, invoker: Any, controller: str) -> None:
188
+ known = self._known_actions(invoker, controller)
189
+ if known is not None and name not in known:
190
+ raise AttributeError(f"unknown action {name!r} for controller {controller!r}")
191
+
192
+ def _validate_attr(self, name: str, invoker: Any, controller: str | None) -> None:
193
+ if self._is_reserved(name):
194
+ raise AttributeError(name)
195
+ if self._missing_context(invoker, controller):
196
+ raise AttributeError(name)
197
+ if self.__dict__.get("_strict") is False:
198
+ return
199
+ self._raise_if_unknown(name, invoker, cast(str, controller))
200
+
201
+ def __getattr__(self, name: str) -> ActionCaller:
202
+ invoker = self.__dict__.get("_invoker")
203
+ controller = self.__dict__.get("controller")
204
+ self._validate_attr(name, invoker, controller)
205
+ if invoker is None or controller is None: # pragma: no cover - checked above
206
+ raise AttributeError(name)
207
+ return ActionCaller(
208
+ invoker,
209
+ controller,
210
+ name,
211
+ strict=self.__dict__.get("_strict"),
212
+ )
213
+
214
+ def __dir__(self) -> list[str]:
215
+ names = set(super().__dir__())
216
+ with contextlib.suppress(UGOSError):
217
+ names.update(self._invoker.available_actions(self.controller))
218
+ return sorted(names)
219
+
220
+ def __repr__(self) -> str:
221
+ return f"{type(self).__name__}(controller={self.controller!r})"
ugos/_encoding.py ADDED
@@ -0,0 +1,159 @@
1
+ """Form encoding used by the web console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ from collections.abc import Collection, Iterable, Mapping
9
+ from datetime import date, datetime
10
+ from decimal import Decimal
11
+ from enum import Enum
12
+ from typing import Any
13
+ from urllib.parse import quote, urlencode
14
+
15
+
16
+ def _json_default(value: Any) -> Any:
17
+ if isinstance(value, (date, datetime)):
18
+ return value.isoformat()
19
+ if isinstance(value, Decimal):
20
+ return str(value)
21
+ if isinstance(value, Enum):
22
+ return value.value
23
+ if isinstance(value, os.PathLike):
24
+ return str(os.fspath(value))
25
+ raise TypeError(f"value of type {type(value).__name__} is not JSON serializable")
26
+
27
+
28
+ def _format_float(value: float, key: str) -> str:
29
+ if math.isnan(value) or math.isinf(value):
30
+ raise ValueError(f"parameter {key!r} must be a finite number")
31
+ return repr(value)
32
+
33
+
34
+ def _scalar_complex(value: Any, key: str) -> str:
35
+ if isinstance(value, Decimal):
36
+ return str(value)
37
+ if isinstance(value, Enum):
38
+ return _scalar(value.value, key)
39
+ if isinstance(value, os.PathLike):
40
+ return str(os.fspath(value))
41
+ raise TypeError(f"parameter {key!r} has unsupported type {type(value).__name__}")
42
+
43
+
44
+ def _scalar_text(value: Any) -> str | None:
45
+ if isinstance(value, bool):
46
+ return "true" if value else "false"
47
+ if isinstance(value, str):
48
+ return value
49
+ return None
50
+
51
+
52
+ def _scalar_number(value: Any, key: str) -> str | None:
53
+ if isinstance(value, int):
54
+ return str(value)
55
+ if isinstance(value, float):
56
+ return _format_float(value, key)
57
+ return None
58
+
59
+
60
+ def _scalar_simple(value: Any, key: str) -> str | None:
61
+ text = _scalar_text(value)
62
+ if text is not None:
63
+ return text
64
+ return _scalar_number(value, key)
65
+
66
+
67
+ def _scalar(value: Any, key: str) -> str:
68
+ simple = _scalar_simple(value, key)
69
+ if simple is not None:
70
+ return simple
71
+ if isinstance(value, (datetime, date)):
72
+ return value.isoformat()
73
+ return _scalar_complex(value, key)
74
+
75
+
76
+ def _json_dumps(value: Any) -> str:
77
+ return json.dumps(value, default=_json_default, ensure_ascii=False, separators=(",", ":"))
78
+
79
+
80
+ def _encode_none(key: str, none: str) -> list[tuple[str, str]]:
81
+ if none == "omit":
82
+ return []
83
+ return [(key, "" if none == "empty" else "null")]
84
+
85
+
86
+ def _sequence_as_json(values: list[Any], list_mode: str) -> bool:
87
+ if list_mode == "json":
88
+ return True
89
+ return any(isinstance(item, (Mapping, list, tuple, set, frozenset)) for item in values)
90
+
91
+
92
+ def _encode_sequence(
93
+ key: str,
94
+ value: Any,
95
+ list_mode: str,
96
+ ) -> list[tuple[str, str]]:
97
+ values = list(value)
98
+ if _sequence_as_json(values, list_mode):
99
+ return [(key, _json_dumps(values))]
100
+ return [(key, _scalar(item, key)) for item in values if item is not None]
101
+
102
+
103
+ def _encode_value(
104
+ key: str,
105
+ value: Any,
106
+ json_fields: Collection[str],
107
+ none: str,
108
+ list_mode: str,
109
+ ) -> list[tuple[str, str]]:
110
+ if value is None:
111
+ return _encode_none(key, none)
112
+ if key in json_fields:
113
+ return [(key, _json_dumps(value))]
114
+ if isinstance(value, (list, tuple, set, frozenset)):
115
+ return _encode_sequence(key, value, list_mode)
116
+ if isinstance(value, Mapping):
117
+ return [(key, _json_dumps(value))]
118
+ return [(key, _scalar(value, key))]
119
+
120
+
121
+ def encode_pairs(
122
+ params: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None,
123
+ *,
124
+ json_fields: Collection[str] = (),
125
+ none: str = "omit",
126
+ list_mode: str = "repeat",
127
+ ) -> list[tuple[str, str]]:
128
+ """Convert Python values into form pairs.
129
+
130
+ Scalars become strings, booleans become ``true``/``false``, lists are sent
131
+ as repeated keys, and mappings are JSON encoded. ``json_fields`` forces
132
+ JSON encoding for individual keys.
133
+ """
134
+
135
+ if params is None:
136
+ return []
137
+ source: Iterable[tuple[str, Any]] = params.items() if isinstance(params, Mapping) else params
138
+ pairs: list[tuple[str, str]] = []
139
+ for raw_key, value in source:
140
+ pairs.extend(_encode_value(str(raw_key), value, json_fields, none, list_mode))
141
+ return pairs
142
+
143
+
144
+ def encode_params(
145
+ params: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None,
146
+ *,
147
+ json_fields: Collection[str] = (),
148
+ none: str = "omit",
149
+ list_mode: str = "repeat",
150
+ ) -> str:
151
+ """Return an ``application/x-www-form-urlencoded`` body."""
152
+
153
+ pairs = encode_pairs(
154
+ params,
155
+ json_fields=json_fields,
156
+ none=none,
157
+ list_mode=list_mode,
158
+ )
159
+ return urlencode(pairs, doseq=False, quote_via=quote)
ugos/_pagination.py ADDED
@@ -0,0 +1,158 @@
1
+ """Pagination helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ from .exceptions import UGOSError
9
+ from .models.common import ListResult
10
+
11
+
12
+ def _stable_key(item: Any) -> str | None:
13
+ if isinstance(item, dict):
14
+ for key in ("id", "guid"):
15
+ value = item.get(key)
16
+ if value is not None and value != "":
17
+ return f"{key}:{value!r}"
18
+ return None
19
+
20
+
21
+ def _validate_sizes(page_size: int, start: int, max_pages: int) -> None:
22
+ if page_size <= 0:
23
+ raise ValueError("page_size must be positive")
24
+ if start < 0:
25
+ raise ValueError("start must be non-negative")
26
+ if max_pages <= 0:
27
+ raise ValueError("max_pages must be positive")
28
+
29
+
30
+ def _validate_max_items(max_items: int | None) -> None:
31
+ if max_items is not None and max_items < 0:
32
+ raise ValueError("max_items must be non-negative")
33
+
34
+
35
+ def _validate_reserved(params: dict[str, Any]) -> None:
36
+ for reserved in ("start", "limit"):
37
+ if reserved in params:
38
+ raise ValueError(f"{reserved!r} is managed by iter_all")
39
+
40
+
41
+ def _page_signature(items: list[Any]) -> tuple[str, ...]:
42
+ return tuple(_stable_key(item) or f"repr:{item!r}" for item in items)
43
+
44
+
45
+ def _record_page(signature: tuple[str, ...], seen_pages: set[tuple[str, ...]]) -> bool:
46
+ if signature in seen_pages:
47
+ return False
48
+ seen_pages.add(signature)
49
+ return True
50
+
51
+
52
+ def _max_reached(max_items: int | None, yielded: int, accepted: int) -> bool:
53
+ return max_items is not None and yielded + accepted >= max_items
54
+
55
+
56
+ def _should_take_item(
57
+ item: Any,
58
+ seen_items: set[str],
59
+ max_items: int | None,
60
+ yielded: int,
61
+ accepted: int,
62
+ ) -> bool:
63
+ if _max_reached(max_items, yielded, accepted):
64
+ return False
65
+ key = _stable_key(item)
66
+ if key is not None and key in seen_items:
67
+ return False
68
+ if key is not None:
69
+ seen_items.add(key)
70
+ return True
71
+
72
+
73
+ def _new_items(
74
+ items: list[Any],
75
+ seen_items: set[str],
76
+ max_items: int | None,
77
+ yielded: int,
78
+ ) -> list[Any]:
79
+ result: list[Any] = []
80
+ for item in items:
81
+ if not _should_take_item(item, seen_items, max_items, yielded, len(result)):
82
+ break
83
+ result.append(item)
84
+ return result
85
+
86
+
87
+ def _reached_total(result: ListResult[Any], yielded: int) -> bool:
88
+ return result.total is not None and yielded >= result.total
89
+
90
+
91
+ def _process_page(
92
+ result: ListResult[Any],
93
+ seen_pages: set[tuple[str, ...]],
94
+ seen_items: set[str],
95
+ max_items: int | None,
96
+ yielded: int,
97
+ ) -> tuple[list[Any], bool]:
98
+ if not result.items:
99
+ return [], True
100
+ if not _record_page(_page_signature(result.items), seen_pages):
101
+ return [], True
102
+ new_items = _new_items(result.items, seen_items, max_items, yielded)
103
+ if not new_items:
104
+ return [], True
105
+ if _max_reached(max_items, yielded, len(new_items)):
106
+ return new_items, True
107
+ return new_items, _reached_total(result, yielded + len(new_items))
108
+
109
+
110
+ def paginate(
111
+ invoker: Any,
112
+ controller: str,
113
+ action: str,
114
+ *,
115
+ page_size: int = 100,
116
+ start: int = 0,
117
+ max_items: int | None = None,
118
+ max_pages: int = 1000,
119
+ strict: bool | None = None,
120
+ **params: Any,
121
+ ) -> Iterator[Any]:
122
+ """Yield items page by page.
123
+
124
+ Stops on an empty page or when a page repeats. The latter protects
125
+ against actions such as ``netzones/ZonesList`` that ignore ``start``.
126
+ """
127
+
128
+ _validate_sizes(page_size, start, max_pages)
129
+ _validate_max_items(max_items)
130
+ if max_items == 0:
131
+ return
132
+ _validate_reserved(params)
133
+
134
+ seen_pages: set[tuple[str, ...]] = set()
135
+ seen_items: set[str] = set()
136
+ current = start
137
+ yielded = 0
138
+ pages = 0
139
+
140
+ while pages < max_pages:
141
+ pages += 1
142
+ data = invoker.call(
143
+ controller,
144
+ action,
145
+ start=current,
146
+ limit=page_size,
147
+ strict=strict,
148
+ **params,
149
+ )
150
+ result: ListResult[Any] = ListResult.from_response(data)
151
+ new_items, stop = _process_page(result, seen_pages, seen_items, max_items, yielded)
152
+ yield from new_items
153
+ yielded += len(new_items)
154
+ if stop:
155
+ return
156
+ current += len(result.items)
157
+
158
+ raise UGOSError(f"pagination limit exceeded for {controller}/{action}")
ugos/_redaction.py ADDED
@@ -0,0 +1,158 @@
1
+ """Helpers that keep secrets out of logs and ``repr()`` output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ _SENSITIVE_PARTS = {
11
+ "password",
12
+ "passwd",
13
+ "pwd",
14
+ "secret",
15
+ "token",
16
+ "csrfmiddlewaretoken",
17
+ "session",
18
+ "cookie",
19
+ "authorization",
20
+ "api",
21
+ "key",
22
+ "privatekey",
23
+ "licensekey",
24
+ "accesskey",
25
+ "credential",
26
+ "credentials",
27
+ }
28
+ _SENSITIVE_WORDS = (
29
+ "password|passwd|pwd|secret|token|csrfmiddlewaretoken|csrf_token|"
30
+ "auth_token|session|session_id|_console_session|api_key|apikey|"
31
+ "private_key|license_key|access_key|authorization|cookie"
32
+ )
33
+ _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
34
+
35
+ _TEXT_PATTERNS = (
36
+ (re.compile(r"(CSRF_TOKEN\s*=\s*['\"])[^'\"]+(['\"])", re.IGNORECASE), r"\1***\2"),
37
+ (re.compile(r"(csrfmiddlewaretoken=)[^&\s]+", re.IGNORECASE), r"\1***"),
38
+ (re.compile(r"(_console_session=)[^;\s]+", re.IGNORECASE), r"\1***"),
39
+ (
40
+ re.compile(
41
+ rf'("[\w.-]*(?:{_SENSITIVE_WORDS})[\w.-]*"\s*:\s*")[^"]+(")',
42
+ re.IGNORECASE,
43
+ ),
44
+ r"\1***\2",
45
+ ),
46
+ (
47
+ re.compile(
48
+ rf"('[\w.-]*(?:{_SENSITIVE_WORDS})[\w.-]*'\s*:\s*')[^']+(')",
49
+ re.IGNORECASE,
50
+ ),
51
+ r"\1***\2",
52
+ ),
53
+ (
54
+ re.compile(
55
+ r"((?:password|passwd|secret|token|api[_-]?key|license[_-]?key)"
56
+ r"\s*[:=]\s*['\"])[^'\"]+(['\"])",
57
+ re.IGNORECASE,
58
+ ),
59
+ r"\1***\2",
60
+ ),
61
+ (
62
+ re.compile(
63
+ r"((?:password|passwd|secret|token|api[_-]?key|license[_-]?key)"
64
+ r"\s*[:=]\s*)[^\s,;]+",
65
+ re.IGNORECASE,
66
+ ),
67
+ r"\1***",
68
+ ),
69
+ (
70
+ re.compile(r"(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+", re.IGNORECASE),
71
+ r"\1***",
72
+ ),
73
+ (
74
+ re.compile(r"((?:set-)?cookie\s*[:=]\s*)[^\s,;]+", re.IGNORECASE),
75
+ r"\1***",
76
+ ),
77
+ (
78
+ re.compile(
79
+ r"([?&](?:api[_-]?key|apikey|token|password|secret|session_id|access_token)=)[^&\s]+",
80
+ re.IGNORECASE,
81
+ ),
82
+ r"\1***",
83
+ ),
84
+ (
85
+ re.compile(
86
+ r'(name=["\']?(?:csrfmiddlewaretoken|password|passwd|secret|token)'
87
+ r'["\']?[^>]*value=["\'])[^"\']+(["\'])',
88
+ re.IGNORECASE,
89
+ ),
90
+ r"\1***\2",
91
+ ),
92
+ )
93
+
94
+
95
+ def _key_parts(key: object) -> list[str]:
96
+ text = _CAMEL_BOUNDARY.sub("_", str(key))
97
+ normalized = re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower()
98
+ return [part for part in normalized.split("_") if part]
99
+
100
+
101
+ def is_sensitive_key(key: object) -> bool:
102
+ parts = _key_parts(key)
103
+ return bool(set(parts) & _SENSITIVE_PARTS)
104
+
105
+
106
+ def redact_value(value: Any) -> Any:
107
+ if isinstance(value, Mapping):
108
+ return redact_mapping(value)
109
+ if isinstance(value, (list, tuple)):
110
+ return [redact_value(item) for item in value]
111
+ if isinstance(value, str):
112
+ return redact_text(value)
113
+ return value
114
+
115
+
116
+ def redact_mapping(data: Mapping[Any, Any]) -> dict[Any, Any]:
117
+ result: dict[Any, Any] = {}
118
+ for key, value in data.items():
119
+ if is_sensitive_key(key):
120
+ result[key] = "***"
121
+ else:
122
+ result[key] = redact_value(value)
123
+ return result
124
+
125
+
126
+ def redact_text(text: str) -> str:
127
+ stripped = text.lstrip()
128
+ if stripped.startswith(("{", "[")):
129
+ try:
130
+ payload = json.loads(text)
131
+ except json.JSONDecodeError:
132
+ pass
133
+ else:
134
+ return json.dumps(redact_value(payload), ensure_ascii=False)
135
+ result = text
136
+ for pattern, replacement in _TEXT_PATTERNS:
137
+ result = pattern.sub(replacement, result)
138
+ return result
139
+
140
+
141
+ def redact_headers(headers: Mapping[str, str]) -> dict[str, str]:
142
+ allowed = {
143
+ "content-type",
144
+ "content-length",
145
+ "content-disposition",
146
+ "accept",
147
+ "location",
148
+ "retry-after",
149
+ "x-requested-with",
150
+ }
151
+ result: dict[str, str] = {}
152
+ for key, value in headers.items():
153
+ lower = key.lower()
154
+ if lower in allowed:
155
+ result[key] = redact_text(value)
156
+ elif lower in {"set-cookie", "cookie", "authorization"}:
157
+ result[key] = "***"
158
+ return result