kstlib 0.0.1a0__py3-none-any.whl → 1.0.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 (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
kstlib/secure/fs.py ADDED
@@ -0,0 +1,194 @@
1
+ """Filesystem guardrails utilities for securing file access.
2
+
3
+ Example:
4
+ Basic usage with a temporary directory::
5
+
6
+ >>> import tempfile
7
+ >>> from kstlib.secure import PathGuardrails, STRICT_POLICY
8
+ >>> with tempfile.TemporaryDirectory() as tmpdir:
9
+ ... guard = PathGuardrails(tmpdir, policy=STRICT_POLICY)
10
+ ... guard.root.is_dir()
11
+ True
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import stat
18
+ from dataclasses import dataclass, replace
19
+ from pathlib import Path
20
+ from typing import Final
21
+
22
+ from kstlib.secure.permissions import DirectoryPermissions
23
+
24
+ __all__ = [
25
+ "RELAXED_POLICY",
26
+ "STRICT_POLICY",
27
+ "GuardPolicy",
28
+ "PathGuardrails",
29
+ "PathSecurityError",
30
+ ]
31
+
32
+
33
+ class PathSecurityError(RuntimeError):
34
+ """Raised when filesystem guardrails detect a security violation."""
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class GuardPolicy:
39
+ """Configuration values defining how guardrails behave.
40
+
41
+ Attributes:
42
+ name: Human-friendly label used for diagnostics.
43
+ allow_external: When True, paths outside the root are accepted.
44
+ auto_create_root: Automatically create the root directory when missing.
45
+ enforce_permissions: Whether POSIX permissions should be validated.
46
+ max_permission_octal: Maximum allowed permission mask (defaults to PRIVATE).
47
+
48
+ Example:
49
+ >>> from kstlib.secure import GuardPolicy
50
+ >>> policy = GuardPolicy(name="custom", allow_external=False)
51
+ >>> policy.name
52
+ 'custom'
53
+ """
54
+
55
+ name: str
56
+ allow_external: bool = False
57
+ auto_create_root: bool = True
58
+ enforce_permissions: bool = True
59
+ max_permission_octal: int = DirectoryPermissions.PRIVATE # 0o700
60
+
61
+
62
+ STRICT_POLICY: Final[GuardPolicy] = GuardPolicy(name="strict")
63
+ RELAXED_POLICY: Final[GuardPolicy] = GuardPolicy(
64
+ name="relaxed",
65
+ allow_external=False,
66
+ auto_create_root=True,
67
+ enforce_permissions=False,
68
+ max_permission_octal=0o777, # No restrictions
69
+ )
70
+
71
+
72
+ class PathGuardrails:
73
+ """Validate and resolve paths relative to a trusted root.
74
+
75
+ Example:
76
+ >>> import tempfile
77
+ >>> from kstlib.secure import PathGuardrails, RELAXED_POLICY
78
+ >>> with tempfile.TemporaryDirectory() as tmpdir:
79
+ ... guard = PathGuardrails(tmpdir, policy=RELAXED_POLICY)
80
+ ... guard.policy.name
81
+ 'relaxed'
82
+ """
83
+
84
+ def __init__(self, root: str | Path, *, policy: GuardPolicy = STRICT_POLICY) -> None:
85
+ """Initialise guardrails rooted at *root* while enforcing *policy*.
86
+
87
+ Raises:
88
+ PathSecurityError: If root does not exist or is not a directory.
89
+ """
90
+ self._policy = policy
91
+ expanded = Path(root).expanduser()
92
+ if policy.auto_create_root:
93
+ expanded.mkdir(parents=True, exist_ok=True, mode=0o755)
94
+ self._root = expanded.resolve()
95
+ if not self._root.exists():
96
+ raise PathSecurityError(f"Guardrail root does not exist: {self._root}")
97
+ if not self._root.is_dir():
98
+ raise PathSecurityError(f"Guardrail root must be a directory: {self._root}")
99
+ self._harden_permissions(self._root)
100
+ self._validate_permissions(self._root)
101
+
102
+ @property
103
+ def root(self) -> Path:
104
+ """Return the resolved guardrail root directory."""
105
+ return self._root
106
+
107
+ @property
108
+ def policy(self) -> GuardPolicy:
109
+ """Return the policy associated with the guardrails."""
110
+ return self._policy
111
+
112
+ def resolve_file(self, candidate: str | Path) -> Path:
113
+ """Resolve *candidate* and ensure it points to an existing file.
114
+
115
+ Raises:
116
+ PathSecurityError: If path is not a file or is outside root.
117
+ """
118
+ path = self._resolve(candidate)
119
+ if not path.is_file():
120
+ raise PathSecurityError(f"Expected file path but found: {path}")
121
+ return path
122
+
123
+ def resolve_directory(self, candidate: str | Path) -> Path:
124
+ """Resolve *candidate* and ensure it points to an existing directory.
125
+
126
+ Raises:
127
+ PathSecurityError: If path is not a directory or is outside root.
128
+ """
129
+ path = self._resolve(candidate)
130
+ if not path.is_dir():
131
+ raise PathSecurityError(f"Expected directory path but found: {path}")
132
+ return path
133
+
134
+ def resolve_path(self, candidate: str | Path) -> Path:
135
+ """Resolve *candidate* relative to the guardrail root without type checks."""
136
+ return self._resolve(candidate, require_exists=False)
137
+
138
+ def relax(self, *, allow_external: bool | None = None) -> PathGuardrails:
139
+ """Return a new guardrail instance with adjusted external allowances."""
140
+ new_policy = replace(
141
+ self._policy,
142
+ allow_external=self._policy.allow_external if allow_external is None else allow_external,
143
+ )
144
+ return PathGuardrails(self._root, policy=new_policy)
145
+
146
+ def _resolve(self, candidate: str | Path, *, require_exists: bool = True) -> Path:
147
+ path = Path(candidate).expanduser()
148
+ if not path.is_absolute():
149
+ path = self._root / path
150
+ resolved = path.resolve()
151
+ self._ensure_within_root(resolved)
152
+ if require_exists and not resolved.exists():
153
+ raise PathSecurityError(f"Resolved path does not exist: {resolved}")
154
+ return resolved
155
+
156
+ def _ensure_within_root(self, path: Path) -> None:
157
+ if self._policy.allow_external:
158
+ return
159
+ if os.name == "nt" and self._root.drive and path.drive.lower() != self._root.drive.lower():
160
+ raise PathSecurityError(f"Path is on a different drive: {path}")
161
+ try:
162
+ path.relative_to(self._root)
163
+ except ValueError as exc:
164
+ raise PathSecurityError(f"Path escapes guardrail root: {path}") from exc
165
+
166
+ def _validate_permissions(self, directory: Path) -> None:
167
+ if not self._policy.enforce_permissions:
168
+ return
169
+ if os.name != "posix":
170
+ return
171
+ # POSIX-only path - tested with real POSIX tests on Linux/macOS
172
+ mode = directory.stat().st_mode # pragma: no cover - POSIX only
173
+ if stat.S_IMODE(mode) & ~self._policy.max_permission_octal: # pragma: no cover - POSIX only
174
+ raise PathSecurityError( # pragma: no cover - POSIX only
175
+ f"Directory {directory} exceeds allowed permissions {oct(self._policy.max_permission_octal)}"
176
+ )
177
+
178
+ def _harden_permissions(self, directory: Path) -> None:
179
+ if not self._policy.enforce_permissions:
180
+ return
181
+ if os.name != "posix":
182
+ return
183
+ # POSIX-only path - tested with real POSIX tests on Linux/macOS
184
+ current_mode = stat.S_IMODE(directory.stat().st_mode) # pragma: no cover - POSIX only
185
+ allowed_mask = self._policy.max_permission_octal # pragma: no cover - POSIX only
186
+ if current_mode & ~allowed_mask == 0: # pragma: no cover - POSIX only
187
+ return # pragma: no cover - POSIX only
188
+ desired_mode = current_mode & allowed_mask # pragma: no cover - POSIX only
189
+ try: # pragma: no cover - POSIX only
190
+ directory.chmod(desired_mode) # pragma: no cover - POSIX only
191
+ except PermissionError as exc: # pragma: no cover - POSIX only, env-specific
192
+ raise PathSecurityError(
193
+ f"Unable to adjust permissions for {directory}; requires <= {oct(allowed_mask)}"
194
+ ) from exc
@@ -0,0 +1,70 @@
1
+ """File permission constants for secure file operations.
2
+
3
+ This module centralizes POSIX permission values to avoid magic numbers
4
+ scattered throughout the codebase.
5
+
6
+ Note:
7
+ On Windows, only read-only vs read-write distinction is supported.
8
+ ``0o400`` becomes ``0o444`` (read-only attribute).
9
+
10
+ Example:
11
+ >>> from kstlib.secure.permissions import FilePermissions # doctest: +SKIP
12
+ >>> path.chmod(FilePermissions.READONLY) # doctest: +SKIP
13
+ """
14
+
15
+ # pylint: disable=too-few-public-methods
16
+ # Justification: These classes are namespace containers for POSIX permission
17
+ # constants, not behavioral objects. They exist to avoid magic numbers and
18
+ # provide grouped, documented constants (e.g., FilePermissions.READONLY).
19
+
20
+ from __future__ import annotations
21
+
22
+
23
+ class FilePermissions:
24
+ """POSIX file permission constants.
25
+
26
+ Attributes:
27
+ READONLY: Owner read-only (0o400). Use for sensitive files like tokens,
28
+ private keys, and secrets. File cannot be modified after creation.
29
+ READONLY_ALL: Read-only for all users (0o444). Use for public documents
30
+ like certificates, CSRs, and public keys.
31
+ OWNER_RW: Owner read-write (0o600). Use for files that need to be
32
+ modified, or temporarily to unlock read-only files before deletion.
33
+ OWNER_RWX: Owner read-write-execute (0o700). Use for directories
34
+ containing sensitive files.
35
+ """
36
+
37
+ # Read-only for owner only - private keys, tokens, secrets
38
+ READONLY: int = 0o400
39
+
40
+ # Read-only for everyone - certificates, public keys
41
+ READONLY_ALL: int = 0o444
42
+
43
+ # Owner read-write - general sensitive files, unlock for deletion
44
+ OWNER_RW: int = 0o600
45
+
46
+ # Owner full access - directories
47
+ OWNER_RWX: int = 0o700
48
+
49
+
50
+ class DirectoryPermissions:
51
+ """POSIX directory permission constants.
52
+
53
+ Attributes:
54
+ PRIVATE: Owner-only access (0o700). Use for directories containing
55
+ sensitive files like tokens or secrets.
56
+ SHARED_READ: Owner full, group/others read+execute (0o755).
57
+ Use for directories with public content.
58
+ """
59
+
60
+ # Private directory - only owner can access
61
+ PRIVATE: int = 0o700
62
+
63
+ # Shared read - owner full, others can read/traverse
64
+ SHARED_READ: int = 0o755
65
+
66
+
67
+ __all__ = [
68
+ "DirectoryPermissions",
69
+ "FilePermissions",
70
+ ]
kstlib/ssl.py ADDED
@@ -0,0 +1,347 @@
1
+ """Global SSL configuration for kstlib.
2
+
3
+ This module provides centralized SSL/TLS configuration with deep defense
4
+ validation for CA bundle paths. All HTTP clients (rapi, auth, alerts)
5
+ can use this module for consistent SSL configuration.
6
+
7
+ Configuration cascade (highest to lowest priority):
8
+ 1. kwargs passed directly to functions
9
+ 2. Module-specific config (e.g., auth.providers.corporate.ssl_verify)
10
+ 3. Global ssl config from kstlib.conf.yml
11
+ 4. Secure defaults (verify=True)
12
+
13
+ Example:
14
+ Global config in kstlib.conf.yml::
15
+
16
+ ssl:
17
+ verify: true
18
+ ca_bundle: /path/to/ca-bundle.crt # null by default
19
+
20
+ Usage in code::
21
+
22
+ from kstlib.ssl import get_ssl_config, build_ssl_context
23
+
24
+ # Get global config
25
+ ssl_cfg = get_ssl_config()
26
+ print(ssl_cfg.verify) # True
27
+
28
+ # Build context for httpx (respects cascade)
29
+ verify = build_ssl_context(ssl_verify=False) # kwargs override
30
+ async with httpx.AsyncClient(verify=verify) as client:
31
+ ...
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from kstlib.config import get_config
42
+ from kstlib.logging import TRACE_LEVEL, get_logger
43
+
44
+ __all__ = [
45
+ "SSLConfig",
46
+ "build_ssl_context",
47
+ "get_ssl_config",
48
+ "validate_ca_bundle_path",
49
+ "validate_ssl_verify",
50
+ ]
51
+
52
+ logger = get_logger(__name__)
53
+
54
+ # Minimum size for a valid PEM certificate (header + minimal content)
55
+ MIN_PEM_SIZE = 50
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class SSLConfig:
60
+ """SSL/TLS configuration container.
61
+
62
+ Immutable dataclass holding SSL settings. Use the ``httpx_verify`` property
63
+ to get the appropriate value for httpx Client/AsyncClient.
64
+
65
+ Attributes:
66
+ verify: Whether to verify SSL certificates.
67
+ ca_bundle: Path to custom CA bundle file (None = system default).
68
+
69
+ Example:
70
+ >>> config = SSLConfig(verify=True, ca_bundle=None)
71
+ >>> config.httpx_verify
72
+ True
73
+ >>> config = SSLConfig(verify=True, ca_bundle="/path/to/ca.pem")
74
+ >>> config.httpx_verify
75
+ '/path/to/ca.pem'
76
+ """
77
+
78
+ verify: bool
79
+ ca_bundle: str | None
80
+
81
+ @property
82
+ def httpx_verify(self) -> bool | str:
83
+ """Return the appropriate verify value for httpx.
84
+
85
+ If a CA bundle is configured, returns the path string.
86
+ Otherwise, returns the boolean verify setting.
87
+
88
+ Returns:
89
+ CA bundle path if set, otherwise verify boolean.
90
+ """
91
+ if self.ca_bundle:
92
+ return self.ca_bundle
93
+ return self.verify
94
+
95
+
96
+ def get_ssl_config() -> SSLConfig:
97
+ """Load SSL configuration from kstlib.conf.yml.
98
+
99
+ Returns the global SSL settings from configuration file.
100
+ Falls back to secure defaults if not configured.
101
+
102
+ Returns:
103
+ SSLConfig with verify and ca_bundle settings.
104
+
105
+ Example:
106
+ >>> config = get_ssl_config() # doctest: +SKIP
107
+ >>> config.verify # doctest: +SKIP
108
+ True
109
+ """
110
+ config = get_config()
111
+ ssl_section = config.get("ssl", {}) # type: ignore[no-untyped-call]
112
+
113
+ verify = ssl_section.get("verify", True)
114
+ ca_bundle = ssl_section.get("ca_bundle")
115
+
116
+ # Validate loaded values
117
+ verify = validate_ssl_verify(verify)
118
+
119
+ if ca_bundle is not None:
120
+ ca_bundle = validate_ca_bundle_path(ca_bundle)
121
+
122
+ return SSLConfig(verify=verify, ca_bundle=ca_bundle)
123
+
124
+
125
+ def build_ssl_context(
126
+ ssl_verify: bool | None = None,
127
+ ssl_ca_bundle: str | None = None,
128
+ ) -> bool | str:
129
+ """Build SSL context for httpx with cascade priority.
130
+
131
+ Cascade order (highest priority first):
132
+ 1. Explicit kwargs (ssl_verify, ssl_ca_bundle)
133
+ 2. Global config from kstlib.conf.yml
134
+ 3. Secure default (verify=True)
135
+
136
+ Args:
137
+ ssl_verify: Override SSL verification (True/False).
138
+ ssl_ca_bundle: Override CA bundle path.
139
+
140
+ Returns:
141
+ Value suitable for httpx verify parameter:
142
+ - bool: True/False for system CA verification
143
+ - str: Path to custom CA bundle
144
+
145
+ Example:
146
+ >>> # Use global config
147
+ >>> verify = build_ssl_context() # doctest: +SKIP
148
+
149
+ >>> # Override with kwargs
150
+ >>> verify = build_ssl_context(ssl_verify=False) # doctest: +SKIP
151
+ >>> verify # doctest: +SKIP
152
+ False
153
+
154
+ >>> # Custom CA bundle
155
+ >>> verify = build_ssl_context(ssl_ca_bundle="/path/to/ca.pem") # doctest: +SKIP
156
+ """
157
+ # Load global config as base
158
+ global_config = get_ssl_config()
159
+
160
+ # Determine effective values (kwargs override global)
161
+ effective_verify = ssl_verify if ssl_verify is not None else global_config.verify
162
+ effective_ca_bundle = ssl_ca_bundle if ssl_ca_bundle is not None else global_config.ca_bundle
163
+
164
+ # Validate kwargs if provided
165
+ if ssl_verify is not None:
166
+ effective_verify = validate_ssl_verify(ssl_verify)
167
+
168
+ if ssl_ca_bundle is not None:
169
+ effective_ca_bundle = validate_ca_bundle_path(ssl_ca_bundle)
170
+
171
+ # Build result
172
+ if effective_ca_bundle:
173
+ if logger.isEnabledFor(TRACE_LEVEL):
174
+ logger.log(TRACE_LEVEL, "[SSL] Using CA bundle: %s", effective_ca_bundle)
175
+ return effective_ca_bundle
176
+
177
+ if logger.isEnabledFor(TRACE_LEVEL):
178
+ logger.log(TRACE_LEVEL, "[SSL] Verify: %s", effective_verify)
179
+
180
+ return effective_verify
181
+
182
+
183
+ def validate_ssl_verify(value: Any) -> bool:
184
+ """Validate ssl_verify with strict type check.
185
+
186
+ Ensures the value is a boolean and logs a security warning
187
+ if certificate verification is disabled.
188
+
189
+ Args:
190
+ value: Value to validate (should be bool).
191
+
192
+ Returns:
193
+ Validated boolean value.
194
+
195
+ Raises:
196
+ TypeError: If value is not a bool (YAML may pass "true" string).
197
+
198
+ Example:
199
+ >>> validate_ssl_verify(True)
200
+ True
201
+ >>> validate_ssl_verify("true") # doctest: +IGNORE_EXCEPTION_DETAIL
202
+ Traceback (most recent call last):
203
+ TypeError: ssl_verify must be bool, got str: 'true'
204
+ """
205
+ if not isinstance(value, bool):
206
+ msg = f"ssl_verify must be bool, got {type(value).__name__}: {value!r}"
207
+ raise TypeError(msg)
208
+
209
+ if not value:
210
+ logger.warning(
211
+ "[SECURITY] ssl_verify=False disables certificate validation. "
212
+ "This exposes you to MITM attacks. Use only for development."
213
+ )
214
+
215
+ return value
216
+
217
+
218
+ def _validate_ca_bundle_string(path_str: str) -> None:
219
+ """Validate CA bundle path string (layers 1-3).
220
+
221
+ Args:
222
+ path_str: Path string to validate.
223
+
224
+ Raises:
225
+ TypeError: If path is not a string.
226
+ ValueError: If path contains null bytes or is empty.
227
+ """
228
+ # Layer 1: Type check
229
+ path_value: Any = path_str
230
+ if not isinstance(path_value, str):
231
+ msg = f"ssl_ca_bundle must be str, got {type(path_value).__name__}"
232
+ raise TypeError(msg)
233
+
234
+ # Layer 2: Null byte injection
235
+ if "\x00" in path_str:
236
+ msg = "ssl_ca_bundle path contains null byte (potential injection attack)"
237
+ raise ValueError(msg)
238
+
239
+ # Layer 3: Empty string
240
+ if not path_str.strip():
241
+ msg = "ssl_ca_bundle cannot be empty string"
242
+ raise ValueError(msg)
243
+
244
+
245
+ def _resolve_ca_bundle_path(path_str: str) -> Path:
246
+ """Resolve CA bundle path (layer 4).
247
+
248
+ Args:
249
+ path_str: Path string to resolve.
250
+
251
+ Returns:
252
+ Resolved Path object.
253
+
254
+ Raises:
255
+ ValueError: If path does not exist or cannot be accessed.
256
+ """
257
+ try:
258
+ return Path(path_str).expanduser().resolve(strict=True)
259
+ except FileNotFoundError:
260
+ msg = f"ssl_ca_bundle path does not exist: {path_str}"
261
+ raise ValueError(msg) from None
262
+ except OSError as e:
263
+ msg = f"ssl_ca_bundle path error: {path_str} ({e})"
264
+ raise ValueError(msg) from None
265
+
266
+
267
+ def _validate_ca_bundle_file(ca_path: Path, original_path: str) -> int:
268
+ """Validate CA bundle file properties (layers 5-7).
269
+
270
+ Args:
271
+ ca_path: Resolved path to CA bundle file.
272
+ original_path: Original path string for error messages.
273
+
274
+ Returns:
275
+ File size in bytes.
276
+
277
+ Raises:
278
+ ValueError: If file is invalid.
279
+ """
280
+ # Layer 5: File type
281
+ if not ca_path.is_file():
282
+ msg = f"ssl_ca_bundle must be a file, not directory: {original_path}"
283
+ raise ValueError(msg)
284
+
285
+ # Layer 6: Readable
286
+ if not os.access(ca_path, os.R_OK):
287
+ msg = f"ssl_ca_bundle file is not readable: {original_path}"
288
+ raise ValueError(msg)
289
+
290
+ # Layer 7: PEM format validation
291
+ file_size = ca_path.stat().st_size
292
+ if file_size < MIN_PEM_SIZE:
293
+ msg = f"ssl_ca_bundle file too small ({file_size} bytes): {original_path}"
294
+ raise ValueError(msg)
295
+
296
+ try:
297
+ with ca_path.open("r", encoding="utf-8") as f:
298
+ header = f.read(1024)
299
+ if "-----BEGIN" not in header:
300
+ msg = f"ssl_ca_bundle does not appear to be PEM format: {original_path}"
301
+ raise ValueError(msg)
302
+ except UnicodeDecodeError:
303
+ msg = f"ssl_ca_bundle is not valid text/PEM file: {original_path}"
304
+ raise ValueError(msg) from None
305
+
306
+ return file_size
307
+
308
+
309
+ def validate_ca_bundle_path(path_str: str) -> str:
310
+ """Validate and normalize CA bundle path with deep defense.
311
+
312
+ Performs 7 layers of security validation:
313
+ 1. Type check (must be string)
314
+ 2. Null byte injection check
315
+ 3. Empty/whitespace check
316
+ 4. Path existence check
317
+ 5. File type check (not directory)
318
+ 6. Readability check
319
+ 7. PEM format validation
320
+
321
+ Args:
322
+ path_str: Path to CA bundle file.
323
+
324
+ Returns:
325
+ Normalized absolute path (symlinks resolved).
326
+
327
+ Raises:
328
+ TypeError: If path is not a string.
329
+ ValueError: If validation fails at any layer.
330
+
331
+ Example:
332
+ >>> validate_ca_bundle_path("/etc/ssl/certs/ca-certificates.crt") # doctest: +SKIP
333
+ '/etc/ssl/certs/ca-certificates.crt'
334
+ """
335
+ # Layers 1-3: String validation
336
+ _validate_ca_bundle_string(path_str)
337
+
338
+ # Layer 4: Path resolution
339
+ ca_path = _resolve_ca_bundle_path(path_str)
340
+
341
+ # Layers 5-7: File validation
342
+ file_size = _validate_ca_bundle_file(ca_path, path_str)
343
+
344
+ if logger.isEnabledFor(TRACE_LEVEL):
345
+ logger.log(TRACE_LEVEL, "[SSL] CA bundle validated: %s (%d bytes)", ca_path, file_size)
346
+
347
+ return str(ca_path)
kstlib/ui/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """UI helper utilities for kstlib."""
2
+
3
+ from kstlib.ui.exceptions import PanelRenderingError, SpinnerError, TableRenderingError
4
+ from kstlib.ui.panels import PanelManager
5
+ from kstlib.ui.spinner import (
6
+ Spinner,
7
+ SpinnerAnimationType,
8
+ SpinnerPosition,
9
+ SpinnerStyle,
10
+ )
11
+ from kstlib.ui.tables import TableBuilder
12
+
13
+ __all__ = [
14
+ "PanelManager",
15
+ "PanelRenderingError",
16
+ "Spinner",
17
+ "SpinnerAnimationType",
18
+ "SpinnerError",
19
+ "SpinnerPosition",
20
+ "SpinnerStyle",
21
+ "TableBuilder",
22
+ "TableRenderingError",
23
+ ]
@@ -0,0 +1,26 @@
1
+ """Specialised exceptions raised by the ``kstlib.ui`` helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "PanelRenderingError",
7
+ "SpinnerError",
8
+ "TableRenderingError",
9
+ ]
10
+
11
+
12
+ class PanelRenderingError(RuntimeError):
13
+ """Raised when building a Rich panel fails.
14
+
15
+ The error captures situations where the ``PanelManager`` cannot resolve
16
+ the requested preset, where override values are invalid, or when the
17
+ payload cannot be converted into a Rich renderable.
18
+ """
19
+
20
+
21
+ class TableRenderingError(RuntimeError):
22
+ """Raised when building a Rich table fails."""
23
+
24
+
25
+ class SpinnerError(RuntimeError):
26
+ """Raised when the spinner encounters an error."""