truthound-dashboard 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.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Encryption utilities for sensitive data protection.
|
|
2
|
+
|
|
3
|
+
This module provides encryption/decryption functionality for sensitive
|
|
4
|
+
configuration data like database passwords, API keys, and tokens.
|
|
5
|
+
|
|
6
|
+
Uses Fernet symmetric encryption from the cryptography library.
|
|
7
|
+
Keys are automatically generated and stored securely.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
# Encrypt a value
|
|
11
|
+
encrypted = encrypt_value("my_secret_password")
|
|
12
|
+
|
|
13
|
+
# Decrypt a value
|
|
14
|
+
decrypted = decrypt_value(encrypted)
|
|
15
|
+
|
|
16
|
+
# Encrypt entire config
|
|
17
|
+
config = {"password": "secret", "host": "localhost"}
|
|
18
|
+
encrypted_config = encrypt_config(config)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import hashlib
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Lazy import cryptography to make it optional
|
|
33
|
+
_fernet_available = True
|
|
34
|
+
try:
|
|
35
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
36
|
+
except ImportError:
|
|
37
|
+
_fernet_available = False
|
|
38
|
+
Fernet = None
|
|
39
|
+
InvalidToken = Exception
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Fields that should be encrypted when storing
|
|
43
|
+
SENSITIVE_FIELDS = frozenset({
|
|
44
|
+
"password",
|
|
45
|
+
"secret",
|
|
46
|
+
"token",
|
|
47
|
+
"api_key",
|
|
48
|
+
"private_key",
|
|
49
|
+
"access_key",
|
|
50
|
+
"secret_key",
|
|
51
|
+
"webhook_url",
|
|
52
|
+
"connection_string",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EncryptionError(Exception):
|
|
57
|
+
"""Raised when encryption/decryption fails."""
|
|
58
|
+
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EncryptionKeyManager:
|
|
63
|
+
"""Manages encryption keys for the application.
|
|
64
|
+
|
|
65
|
+
Handles key generation, storage, and retrieval with proper
|
|
66
|
+
file permissions for security.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, key_path: Path | None = None) -> None:
|
|
70
|
+
"""Initialize key manager.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
key_path: Path to store encryption key. Defaults to ~/.truthound/.key
|
|
74
|
+
"""
|
|
75
|
+
from truthound_dashboard.config import get_settings
|
|
76
|
+
|
|
77
|
+
settings = get_settings()
|
|
78
|
+
self._key_path = key_path or (settings.data_dir / ".key")
|
|
79
|
+
self._key: bytes | None = None
|
|
80
|
+
|
|
81
|
+
def _ensure_directory(self) -> None:
|
|
82
|
+
"""Ensure key directory exists with proper permissions."""
|
|
83
|
+
self._key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
def generate_key(self) -> bytes:
|
|
86
|
+
"""Generate a new encryption key.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
New Fernet key.
|
|
90
|
+
"""
|
|
91
|
+
if not _fernet_available:
|
|
92
|
+
raise EncryptionError(
|
|
93
|
+
"cryptography package not installed. "
|
|
94
|
+
"Install with: pip install cryptography"
|
|
95
|
+
)
|
|
96
|
+
return Fernet.generate_key()
|
|
97
|
+
|
|
98
|
+
def derive_key(self, password: str, salt: bytes | None = None) -> bytes:
|
|
99
|
+
"""Derive encryption key from password.
|
|
100
|
+
|
|
101
|
+
Uses PBKDF2-like derivation for key generation from password.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
password: Password to derive key from.
|
|
105
|
+
salt: Optional salt for derivation. Generates random if not provided.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Derived encryption key.
|
|
109
|
+
"""
|
|
110
|
+
if salt is None:
|
|
111
|
+
salt = os.urandom(16)
|
|
112
|
+
|
|
113
|
+
# Simple derivation using SHA-256
|
|
114
|
+
# For production, consider using proper PBKDF2 or Argon2
|
|
115
|
+
key_material = hashlib.pbkdf2_hmac(
|
|
116
|
+
"sha256",
|
|
117
|
+
password.encode(),
|
|
118
|
+
salt,
|
|
119
|
+
iterations=100000,
|
|
120
|
+
)
|
|
121
|
+
return base64.urlsafe_b64encode(key_material)
|
|
122
|
+
|
|
123
|
+
def get_key(self) -> bytes:
|
|
124
|
+
"""Get or create encryption key.
|
|
125
|
+
|
|
126
|
+
Loads existing key from file or generates new one.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Encryption key bytes.
|
|
130
|
+
"""
|
|
131
|
+
if self._key is not None:
|
|
132
|
+
return self._key
|
|
133
|
+
|
|
134
|
+
if self._key_path.exists():
|
|
135
|
+
self._key = self._key_path.read_bytes()
|
|
136
|
+
return self._key
|
|
137
|
+
|
|
138
|
+
# Generate new key
|
|
139
|
+
self._ensure_directory()
|
|
140
|
+
self._key = self.generate_key()
|
|
141
|
+
self._key_path.write_bytes(self._key)
|
|
142
|
+
|
|
143
|
+
# Restrict file permissions (Unix only)
|
|
144
|
+
try:
|
|
145
|
+
os.chmod(self._key_path, 0o600)
|
|
146
|
+
except (OSError, AttributeError):
|
|
147
|
+
# Windows doesn't support chmod
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
logger.info(f"Generated new encryption key at {self._key_path}")
|
|
151
|
+
return self._key
|
|
152
|
+
|
|
153
|
+
def rotate_key(self, old_key: bytes | None = None) -> bytes:
|
|
154
|
+
"""Rotate encryption key.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
old_key: Old key to backup. Current key used if not provided.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
New encryption key.
|
|
161
|
+
"""
|
|
162
|
+
if old_key is None and self._key_path.exists():
|
|
163
|
+
old_key = self._key_path.read_bytes()
|
|
164
|
+
|
|
165
|
+
# Backup old key
|
|
166
|
+
if old_key:
|
|
167
|
+
backup_path = self._key_path.with_suffix(".key.bak")
|
|
168
|
+
backup_path.write_bytes(old_key)
|
|
169
|
+
logger.info(f"Backed up old key to {backup_path}")
|
|
170
|
+
|
|
171
|
+
# Generate and save new key
|
|
172
|
+
self._key = self.generate_key()
|
|
173
|
+
self._ensure_directory()
|
|
174
|
+
self._key_path.write_bytes(self._key)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
os.chmod(self._key_path, 0o600)
|
|
178
|
+
except (OSError, AttributeError):
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
logger.info("Encryption key rotated successfully")
|
|
182
|
+
return self._key
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Encryptor:
|
|
186
|
+
"""Handles encryption and decryption of values.
|
|
187
|
+
|
|
188
|
+
Uses Fernet symmetric encryption for secure data protection.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(self, key: bytes | None = None) -> None:
|
|
192
|
+
"""Initialize encryptor.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
key: Encryption key. Uses KeyManager if not provided.
|
|
196
|
+
"""
|
|
197
|
+
if not _fernet_available:
|
|
198
|
+
self._fernet = None
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if key is None:
|
|
202
|
+
key = EncryptionKeyManager().get_key()
|
|
203
|
+
|
|
204
|
+
self._fernet = Fernet(key)
|
|
205
|
+
|
|
206
|
+
def encrypt(self, value: str) -> str:
|
|
207
|
+
"""Encrypt a string value.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
value: Plain text value to encrypt.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Encrypted value as base64 string.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
EncryptionError: If encryption fails or not available.
|
|
217
|
+
"""
|
|
218
|
+
if self._fernet is None:
|
|
219
|
+
logger.warning("Encryption not available, returning plain value")
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
encrypted = self._fernet.encrypt(value.encode())
|
|
224
|
+
return encrypted.decode()
|
|
225
|
+
except Exception as e:
|
|
226
|
+
raise EncryptionError(f"Encryption failed: {e}") from e
|
|
227
|
+
|
|
228
|
+
def decrypt(self, encrypted: str) -> str:
|
|
229
|
+
"""Decrypt an encrypted value.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
encrypted: Encrypted base64 string.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Decrypted plain text value.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
EncryptionError: If decryption fails.
|
|
239
|
+
"""
|
|
240
|
+
if self._fernet is None:
|
|
241
|
+
logger.warning("Encryption not available, returning as-is")
|
|
242
|
+
return encrypted
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
decrypted = self._fernet.decrypt(encrypted.encode())
|
|
246
|
+
return decrypted.decode()
|
|
247
|
+
except InvalidToken as e:
|
|
248
|
+
raise EncryptionError("Invalid encryption token or corrupted data") from e
|
|
249
|
+
except Exception as e:
|
|
250
|
+
raise EncryptionError(f"Decryption failed: {e}") from e
|
|
251
|
+
|
|
252
|
+
def is_encrypted(self, value: str) -> bool:
|
|
253
|
+
"""Check if a value appears to be encrypted.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
value: Value to check.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if value appears to be Fernet encrypted.
|
|
260
|
+
"""
|
|
261
|
+
if not value or len(value) < 32:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
# Fernet tokens are base64 encoded and start with 'gAAAAA'
|
|
266
|
+
return value.startswith("gAAAAA")
|
|
267
|
+
except Exception:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Module-level singleton
|
|
272
|
+
_encryptor: Encryptor | None = None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_encryptor() -> Encryptor:
|
|
276
|
+
"""Get encryptor singleton.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Encryptor instance.
|
|
280
|
+
"""
|
|
281
|
+
global _encryptor
|
|
282
|
+
if _encryptor is None:
|
|
283
|
+
_encryptor = Encryptor()
|
|
284
|
+
return _encryptor
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def reset_encryptor() -> None:
|
|
288
|
+
"""Reset encryptor singleton (for testing)."""
|
|
289
|
+
global _encryptor
|
|
290
|
+
_encryptor = None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# Convenience functions
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def encrypt_value(value: str) -> str:
|
|
297
|
+
"""Encrypt a string value.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
value: Plain text value.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Encrypted value.
|
|
304
|
+
"""
|
|
305
|
+
return get_encryptor().encrypt(value)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def decrypt_value(encrypted: str) -> str:
|
|
309
|
+
"""Decrypt an encrypted value.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
encrypted: Encrypted value.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Decrypted plain text.
|
|
316
|
+
"""
|
|
317
|
+
return get_encryptor().decrypt(encrypted)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def is_sensitive_field(field_name: str) -> bool:
|
|
321
|
+
"""Check if a field name indicates sensitive data.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
field_name: Field name to check.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True if field should be encrypted.
|
|
328
|
+
"""
|
|
329
|
+
field_lower = field_name.lower()
|
|
330
|
+
return any(sensitive in field_lower for sensitive in SENSITIVE_FIELDS)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def encrypt_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
334
|
+
"""Encrypt sensitive fields in a configuration dictionary.
|
|
335
|
+
|
|
336
|
+
Recursively encrypts values for fields that match sensitive field patterns.
|
|
337
|
+
Encrypted values are stored with a special marker.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
config: Configuration dictionary.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
New dictionary with sensitive fields encrypted.
|
|
344
|
+
"""
|
|
345
|
+
encryptor = get_encryptor()
|
|
346
|
+
encrypted = {}
|
|
347
|
+
|
|
348
|
+
for key, value in config.items():
|
|
349
|
+
if isinstance(value, dict):
|
|
350
|
+
# Recursively encrypt nested dictionaries
|
|
351
|
+
encrypted[key] = encrypt_config(value)
|
|
352
|
+
elif isinstance(value, str) and value and is_sensitive_field(key):
|
|
353
|
+
# Encrypt sensitive string values
|
|
354
|
+
if not encryptor.is_encrypted(value):
|
|
355
|
+
encrypted[key] = {"_encrypted": encryptor.encrypt(value)}
|
|
356
|
+
else:
|
|
357
|
+
encrypted[key] = {"_encrypted": value}
|
|
358
|
+
else:
|
|
359
|
+
encrypted[key] = value
|
|
360
|
+
|
|
361
|
+
return encrypted
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def decrypt_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
365
|
+
"""Decrypt sensitive fields in a configuration dictionary.
|
|
366
|
+
|
|
367
|
+
Recursively decrypts values that have the encryption marker.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
config: Configuration dictionary with encrypted fields.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
New dictionary with sensitive fields decrypted.
|
|
374
|
+
"""
|
|
375
|
+
encryptor = get_encryptor()
|
|
376
|
+
decrypted = {}
|
|
377
|
+
|
|
378
|
+
for key, value in config.items():
|
|
379
|
+
if isinstance(value, dict):
|
|
380
|
+
if "_encrypted" in value:
|
|
381
|
+
# Decrypt marked field
|
|
382
|
+
try:
|
|
383
|
+
decrypted[key] = encryptor.decrypt(value["_encrypted"])
|
|
384
|
+
except EncryptionError:
|
|
385
|
+
# If decryption fails, keep the encrypted value
|
|
386
|
+
logger.warning(f"Failed to decrypt field: {key}")
|
|
387
|
+
decrypted[key] = value["_encrypted"]
|
|
388
|
+
else:
|
|
389
|
+
# Recursively decrypt nested dictionaries
|
|
390
|
+
decrypted[key] = decrypt_config(value)
|
|
391
|
+
else:
|
|
392
|
+
decrypted[key] = value
|
|
393
|
+
|
|
394
|
+
return decrypted
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def mask_sensitive_value(value: str, visible_chars: int = 4) -> str:
|
|
398
|
+
"""Mask a sensitive value for display.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
value: Value to mask.
|
|
402
|
+
visible_chars: Number of characters to show at end.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Masked value like '***abc'.
|
|
406
|
+
"""
|
|
407
|
+
if len(value) <= visible_chars:
|
|
408
|
+
return "***"
|
|
409
|
+
return "***" + value[-visible_chars:]
|