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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {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
|
+
]
|
kstlib/ui/exceptions.py
ADDED
|
@@ -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."""
|