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.
Files changed (62) hide show
  1. truthound_dashboard/__init__.py +11 -0
  2. truthound_dashboard/__main__.py +6 -0
  3. truthound_dashboard/api/__init__.py +15 -0
  4. truthound_dashboard/api/deps.py +153 -0
  5. truthound_dashboard/api/drift.py +179 -0
  6. truthound_dashboard/api/error_handlers.py +287 -0
  7. truthound_dashboard/api/health.py +78 -0
  8. truthound_dashboard/api/history.py +62 -0
  9. truthound_dashboard/api/middleware.py +626 -0
  10. truthound_dashboard/api/notifications.py +561 -0
  11. truthound_dashboard/api/profile.py +52 -0
  12. truthound_dashboard/api/router.py +83 -0
  13. truthound_dashboard/api/rules.py +277 -0
  14. truthound_dashboard/api/schedules.py +329 -0
  15. truthound_dashboard/api/schemas.py +136 -0
  16. truthound_dashboard/api/sources.py +229 -0
  17. truthound_dashboard/api/validations.py +125 -0
  18. truthound_dashboard/cli.py +226 -0
  19. truthound_dashboard/config.py +132 -0
  20. truthound_dashboard/core/__init__.py +264 -0
  21. truthound_dashboard/core/base.py +185 -0
  22. truthound_dashboard/core/cache.py +479 -0
  23. truthound_dashboard/core/connections.py +331 -0
  24. truthound_dashboard/core/encryption.py +409 -0
  25. truthound_dashboard/core/exceptions.py +627 -0
  26. truthound_dashboard/core/logging.py +488 -0
  27. truthound_dashboard/core/maintenance.py +542 -0
  28. truthound_dashboard/core/notifications/__init__.py +56 -0
  29. truthound_dashboard/core/notifications/base.py +390 -0
  30. truthound_dashboard/core/notifications/channels.py +557 -0
  31. truthound_dashboard/core/notifications/dispatcher.py +453 -0
  32. truthound_dashboard/core/notifications/events.py +155 -0
  33. truthound_dashboard/core/notifications/service.py +744 -0
  34. truthound_dashboard/core/sampling.py +626 -0
  35. truthound_dashboard/core/scheduler.py +311 -0
  36. truthound_dashboard/core/services.py +1531 -0
  37. truthound_dashboard/core/truthound_adapter.py +659 -0
  38. truthound_dashboard/db/__init__.py +67 -0
  39. truthound_dashboard/db/base.py +108 -0
  40. truthound_dashboard/db/database.py +196 -0
  41. truthound_dashboard/db/models.py +732 -0
  42. truthound_dashboard/db/repository.py +237 -0
  43. truthound_dashboard/main.py +309 -0
  44. truthound_dashboard/schemas/__init__.py +150 -0
  45. truthound_dashboard/schemas/base.py +96 -0
  46. truthound_dashboard/schemas/drift.py +118 -0
  47. truthound_dashboard/schemas/history.py +74 -0
  48. truthound_dashboard/schemas/profile.py +91 -0
  49. truthound_dashboard/schemas/rule.py +199 -0
  50. truthound_dashboard/schemas/schedule.py +88 -0
  51. truthound_dashboard/schemas/schema.py +121 -0
  52. truthound_dashboard/schemas/source.py +138 -0
  53. truthound_dashboard/schemas/validation.py +192 -0
  54. truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
  55. truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
  56. truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
  57. truthound_dashboard/static/index.html +15 -0
  58. truthound_dashboard/static/mockServiceWorker.js +349 -0
  59. truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
  60. truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
  61. truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
  62. 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:]