kekkai-cli 1.1.0__py3-none-any.whl → 1.1.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.
- kekkai/cli.py +124 -33
- kekkai/dojo_import.py +9 -1
- kekkai/output.py +1 -1
- kekkai/report/unified.py +226 -0
- kekkai/triage/__init__.py +54 -1
- kekkai/triage/loader.py +196 -0
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/METADATA +33 -13
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/RECORD +11 -27
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/entry_points.txt +0 -1
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/top_level.txt +0 -1
- portal/__init__.py +0 -19
- portal/api.py +0 -155
- portal/auth.py +0 -103
- portal/enterprise/__init__.py +0 -45
- portal/enterprise/audit.py +0 -435
- portal/enterprise/licensing.py +0 -408
- portal/enterprise/rbac.py +0 -276
- portal/enterprise/saml.py +0 -595
- portal/ops/__init__.py +0 -53
- portal/ops/backup.py +0 -553
- portal/ops/log_shipper.py +0 -469
- portal/ops/monitoring.py +0 -517
- portal/ops/restore.py +0 -469
- portal/ops/secrets.py +0 -408
- portal/ops/upgrade.py +0 -591
- portal/tenants.py +0 -340
- portal/uploads.py +0 -259
- portal/web.py +0 -393
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/WHEEL +0 -0
portal/ops/secrets.py
DELETED
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
"""Secret rotation utilities for Kekkai Portal.
|
|
2
|
-
|
|
3
|
-
Provides:
|
|
4
|
-
- API key rotation without downtime
|
|
5
|
-
- Database credential rotation support
|
|
6
|
-
- Rotation schedule management
|
|
7
|
-
|
|
8
|
-
ASVS 5.0 Requirements:
|
|
9
|
-
- V13.1.4: Secret rotation schedule
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import hashlib
|
|
15
|
-
import logging
|
|
16
|
-
import os
|
|
17
|
-
import secrets
|
|
18
|
-
from collections.abc import Callable
|
|
19
|
-
from dataclasses import dataclass, field
|
|
20
|
-
from datetime import UTC, datetime, timedelta
|
|
21
|
-
from enum import Enum
|
|
22
|
-
from pathlib import Path
|
|
23
|
-
from typing import Any
|
|
24
|
-
|
|
25
|
-
logger = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class SecretType(Enum):
|
|
29
|
-
"""Types of secrets that can be rotated."""
|
|
30
|
-
|
|
31
|
-
API_KEY = "api_key"
|
|
32
|
-
DATABASE_PASSWORD = "database_password" # noqa: S105
|
|
33
|
-
ENCRYPTION_KEY = "encryption_key"
|
|
34
|
-
SESSION_SECRET = "session_secret" # noqa: S105
|
|
35
|
-
JWT_SECRET = "jwt_secret" # noqa: S105
|
|
36
|
-
SAML_SIGNING_KEY = "saml_signing_key"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class RotationStatus(Enum):
|
|
40
|
-
"""Status of a rotation operation."""
|
|
41
|
-
|
|
42
|
-
PENDING = "pending"
|
|
43
|
-
IN_PROGRESS = "in_progress"
|
|
44
|
-
COMPLETED = "completed"
|
|
45
|
-
FAILED = "failed"
|
|
46
|
-
ROLLED_BACK = "rolled_back"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@dataclass
|
|
50
|
-
class RotationSchedule:
|
|
51
|
-
"""Schedule for secret rotation."""
|
|
52
|
-
|
|
53
|
-
secret_type: SecretType
|
|
54
|
-
interval_days: int
|
|
55
|
-
last_rotated: datetime | None = None
|
|
56
|
-
next_rotation: datetime | None = None
|
|
57
|
-
enabled: bool = True
|
|
58
|
-
notify_days_before: int = 7
|
|
59
|
-
|
|
60
|
-
def __post_init__(self) -> None:
|
|
61
|
-
if self.last_rotated and not self.next_rotation:
|
|
62
|
-
self.next_rotation = self.last_rotated + timedelta(days=self.interval_days)
|
|
63
|
-
|
|
64
|
-
def is_due(self) -> bool:
|
|
65
|
-
"""Check if rotation is due."""
|
|
66
|
-
if not self.enabled:
|
|
67
|
-
return False
|
|
68
|
-
if not self.next_rotation:
|
|
69
|
-
return True
|
|
70
|
-
return datetime.now(UTC) >= self.next_rotation
|
|
71
|
-
|
|
72
|
-
def should_notify(self) -> bool:
|
|
73
|
-
"""Check if notification should be sent."""
|
|
74
|
-
if not self.enabled or not self.next_rotation:
|
|
75
|
-
return False
|
|
76
|
-
notification_date = self.next_rotation - timedelta(days=self.notify_days_before)
|
|
77
|
-
return datetime.now(UTC) >= notification_date
|
|
78
|
-
|
|
79
|
-
def to_dict(self) -> dict[str, Any]:
|
|
80
|
-
"""Convert to dictionary."""
|
|
81
|
-
return {
|
|
82
|
-
"secret_type": self.secret_type.value,
|
|
83
|
-
"interval_days": self.interval_days,
|
|
84
|
-
"last_rotated": self.last_rotated.isoformat() if self.last_rotated else None,
|
|
85
|
-
"next_rotation": self.next_rotation.isoformat() if self.next_rotation else None,
|
|
86
|
-
"enabled": self.enabled,
|
|
87
|
-
"notify_days_before": self.notify_days_before,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@dataclass
|
|
92
|
-
class RotationResult:
|
|
93
|
-
"""Result of a rotation operation."""
|
|
94
|
-
|
|
95
|
-
success: bool
|
|
96
|
-
secret_type: SecretType
|
|
97
|
-
status: RotationStatus
|
|
98
|
-
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
99
|
-
old_key_hash: str = ""
|
|
100
|
-
new_key_hash: str = ""
|
|
101
|
-
error: str | None = None
|
|
102
|
-
rollback_available: bool = False
|
|
103
|
-
|
|
104
|
-
def to_dict(self) -> dict[str, Any]:
|
|
105
|
-
"""Convert to dictionary."""
|
|
106
|
-
return {
|
|
107
|
-
"success": self.success,
|
|
108
|
-
"secret_type": self.secret_type.value,
|
|
109
|
-
"status": self.status.value,
|
|
110
|
-
"timestamp": self.timestamp.isoformat(),
|
|
111
|
-
"old_key_hash": self.old_key_hash,
|
|
112
|
-
"new_key_hash": self.new_key_hash,
|
|
113
|
-
"error": self.error,
|
|
114
|
-
"rollback_available": self.rollback_available,
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class SecretRotation:
|
|
119
|
-
"""Manages secret rotation for Kekkai Portal."""
|
|
120
|
-
|
|
121
|
-
def __init__(
|
|
122
|
-
self,
|
|
123
|
-
schedules: list[RotationSchedule] | None = None,
|
|
124
|
-
state_path: Path | None = None,
|
|
125
|
-
) -> None:
|
|
126
|
-
self._schedules = {s.secret_type: s for s in (schedules or [])}
|
|
127
|
-
self._state_path = state_path
|
|
128
|
-
self._rotation_handlers: dict[SecretType, Callable[[str, str], bool]] = {}
|
|
129
|
-
self._rollback_store: dict[SecretType, str] = {}
|
|
130
|
-
|
|
131
|
-
if not self._schedules:
|
|
132
|
-
self._schedules = self._get_default_schedules()
|
|
133
|
-
|
|
134
|
-
def _get_default_schedules(self) -> dict[SecretType, RotationSchedule]:
|
|
135
|
-
"""Get default rotation schedules per ASVS recommendations."""
|
|
136
|
-
return {
|
|
137
|
-
SecretType.API_KEY: RotationSchedule(
|
|
138
|
-
secret_type=SecretType.API_KEY,
|
|
139
|
-
interval_days=90,
|
|
140
|
-
enabled=True,
|
|
141
|
-
),
|
|
142
|
-
SecretType.DATABASE_PASSWORD: RotationSchedule(
|
|
143
|
-
secret_type=SecretType.DATABASE_PASSWORD,
|
|
144
|
-
interval_days=90,
|
|
145
|
-
enabled=True,
|
|
146
|
-
),
|
|
147
|
-
SecretType.SESSION_SECRET: RotationSchedule(
|
|
148
|
-
secret_type=SecretType.SESSION_SECRET,
|
|
149
|
-
interval_days=30,
|
|
150
|
-
enabled=True,
|
|
151
|
-
),
|
|
152
|
-
SecretType.JWT_SECRET: RotationSchedule(
|
|
153
|
-
secret_type=SecretType.JWT_SECRET,
|
|
154
|
-
interval_days=90,
|
|
155
|
-
enabled=True,
|
|
156
|
-
),
|
|
157
|
-
SecretType.ENCRYPTION_KEY: RotationSchedule(
|
|
158
|
-
secret_type=SecretType.ENCRYPTION_KEY,
|
|
159
|
-
interval_days=365,
|
|
160
|
-
enabled=True,
|
|
161
|
-
),
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
def register_handler(
|
|
165
|
-
self, secret_type: SecretType, handler: Callable[[str, str], bool]
|
|
166
|
-
) -> None:
|
|
167
|
-
"""Register a rotation handler for a secret type.
|
|
168
|
-
|
|
169
|
-
Handler receives (old_value, new_value) and returns success bool.
|
|
170
|
-
"""
|
|
171
|
-
self._rotation_handlers[secret_type] = handler
|
|
172
|
-
|
|
173
|
-
def generate_api_key(self, prefix: str = "kk") -> str:
|
|
174
|
-
"""Generate a new API key."""
|
|
175
|
-
random_part = secrets.token_urlsafe(32)
|
|
176
|
-
return f"{prefix}_{random_part}"
|
|
177
|
-
|
|
178
|
-
def generate_password(self, length: int = 32) -> str:
|
|
179
|
-
"""Generate a secure random password."""
|
|
180
|
-
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
|
181
|
-
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
182
|
-
|
|
183
|
-
def generate_encryption_key(self, bits: int = 256) -> bytes:
|
|
184
|
-
"""Generate a secure encryption key."""
|
|
185
|
-
return secrets.token_bytes(bits // 8)
|
|
186
|
-
|
|
187
|
-
def rotate_api_key(
|
|
188
|
-
self,
|
|
189
|
-
tenant_id: str,
|
|
190
|
-
current_key: str | None = None,
|
|
191
|
-
grace_period_hours: int = 24,
|
|
192
|
-
) -> RotationResult:
|
|
193
|
-
"""Rotate an API key with optional grace period.
|
|
194
|
-
|
|
195
|
-
During grace period, both old and new keys are valid.
|
|
196
|
-
"""
|
|
197
|
-
start_time = datetime.now(UTC)
|
|
198
|
-
new_key = self.generate_api_key()
|
|
199
|
-
old_key_hash = self._hash_secret(current_key) if current_key else ""
|
|
200
|
-
new_key_hash = self._hash_secret(new_key)
|
|
201
|
-
|
|
202
|
-
try:
|
|
203
|
-
if current_key:
|
|
204
|
-
self._rollback_store[SecretType.API_KEY] = current_key
|
|
205
|
-
|
|
206
|
-
handler = self._rotation_handlers.get(SecretType.API_KEY)
|
|
207
|
-
if handler:
|
|
208
|
-
success = handler(current_key or "", new_key)
|
|
209
|
-
if not success:
|
|
210
|
-
return RotationResult(
|
|
211
|
-
success=False,
|
|
212
|
-
secret_type=SecretType.API_KEY,
|
|
213
|
-
status=RotationStatus.FAILED,
|
|
214
|
-
old_key_hash=old_key_hash,
|
|
215
|
-
error="Handler returned failure",
|
|
216
|
-
rollback_available=bool(current_key),
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
schedule = self._schedules.get(SecretType.API_KEY)
|
|
220
|
-
if schedule:
|
|
221
|
-
schedule.last_rotated = start_time
|
|
222
|
-
schedule.next_rotation = start_time + timedelta(days=schedule.interval_days)
|
|
223
|
-
|
|
224
|
-
logger.info(
|
|
225
|
-
"secret.rotated type=api_key tenant=%s new_hash=%s",
|
|
226
|
-
tenant_id,
|
|
227
|
-
new_key_hash[:16],
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
return RotationResult(
|
|
231
|
-
success=True,
|
|
232
|
-
secret_type=SecretType.API_KEY,
|
|
233
|
-
status=RotationStatus.COMPLETED,
|
|
234
|
-
old_key_hash=old_key_hash,
|
|
235
|
-
new_key_hash=new_key_hash,
|
|
236
|
-
rollback_available=bool(current_key),
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
except Exception as e:
|
|
240
|
-
logger.error("secret.rotation.failed type=api_key error=%s", str(e))
|
|
241
|
-
return RotationResult(
|
|
242
|
-
success=False,
|
|
243
|
-
secret_type=SecretType.API_KEY,
|
|
244
|
-
status=RotationStatus.FAILED,
|
|
245
|
-
old_key_hash=old_key_hash,
|
|
246
|
-
error=f"Rotation failed: {type(e).__name__}",
|
|
247
|
-
rollback_available=bool(current_key),
|
|
248
|
-
)
|
|
249
|
-
|
|
250
|
-
def rotate_database_password(
|
|
251
|
-
self,
|
|
252
|
-
current_password: str | None = None,
|
|
253
|
-
) -> RotationResult:
|
|
254
|
-
"""Rotate database password."""
|
|
255
|
-
new_password = self.generate_password()
|
|
256
|
-
old_hash = self._hash_secret(current_password) if current_password else ""
|
|
257
|
-
new_hash = self._hash_secret(new_password)
|
|
258
|
-
|
|
259
|
-
try:
|
|
260
|
-
if current_password:
|
|
261
|
-
self._rollback_store[SecretType.DATABASE_PASSWORD] = current_password
|
|
262
|
-
|
|
263
|
-
handler = self._rotation_handlers.get(SecretType.DATABASE_PASSWORD)
|
|
264
|
-
if handler:
|
|
265
|
-
success = handler(current_password or "", new_password)
|
|
266
|
-
if not success:
|
|
267
|
-
return RotationResult(
|
|
268
|
-
success=False,
|
|
269
|
-
secret_type=SecretType.DATABASE_PASSWORD,
|
|
270
|
-
status=RotationStatus.FAILED,
|
|
271
|
-
old_key_hash=old_hash,
|
|
272
|
-
error="Handler returned failure",
|
|
273
|
-
rollback_available=bool(current_password),
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
schedule = self._schedules.get(SecretType.DATABASE_PASSWORD)
|
|
277
|
-
if schedule:
|
|
278
|
-
schedule.last_rotated = datetime.now(UTC)
|
|
279
|
-
schedule.next_rotation = schedule.last_rotated + timedelta(
|
|
280
|
-
days=schedule.interval_days
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
logger.info("secret.rotated type=database_password")
|
|
284
|
-
|
|
285
|
-
return RotationResult(
|
|
286
|
-
success=True,
|
|
287
|
-
secret_type=SecretType.DATABASE_PASSWORD,
|
|
288
|
-
status=RotationStatus.COMPLETED,
|
|
289
|
-
old_key_hash=old_hash,
|
|
290
|
-
new_key_hash=new_hash,
|
|
291
|
-
rollback_available=bool(current_password),
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
except Exception as e:
|
|
295
|
-
logger.error("secret.rotation.failed type=database_password error=%s", str(e))
|
|
296
|
-
return RotationResult(
|
|
297
|
-
success=False,
|
|
298
|
-
secret_type=SecretType.DATABASE_PASSWORD,
|
|
299
|
-
status=RotationStatus.FAILED,
|
|
300
|
-
old_key_hash=old_hash,
|
|
301
|
-
error=f"Rotation failed: {type(e).__name__}",
|
|
302
|
-
rollback_available=bool(current_password),
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
def rollback(self, secret_type: SecretType) -> RotationResult:
|
|
306
|
-
"""Rollback a recent rotation."""
|
|
307
|
-
old_value = self._rollback_store.get(secret_type)
|
|
308
|
-
if not old_value:
|
|
309
|
-
return RotationResult(
|
|
310
|
-
success=False,
|
|
311
|
-
secret_type=secret_type,
|
|
312
|
-
status=RotationStatus.FAILED,
|
|
313
|
-
error="No rollback value available",
|
|
314
|
-
)
|
|
315
|
-
|
|
316
|
-
try:
|
|
317
|
-
handler = self._rotation_handlers.get(secret_type)
|
|
318
|
-
if handler:
|
|
319
|
-
success = handler("", old_value)
|
|
320
|
-
if not success:
|
|
321
|
-
return RotationResult(
|
|
322
|
-
success=False,
|
|
323
|
-
secret_type=secret_type,
|
|
324
|
-
status=RotationStatus.FAILED,
|
|
325
|
-
error="Rollback handler failed",
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
del self._rollback_store[secret_type]
|
|
329
|
-
|
|
330
|
-
logger.info("secret.rollback type=%s", secret_type.value)
|
|
331
|
-
|
|
332
|
-
return RotationResult(
|
|
333
|
-
success=True,
|
|
334
|
-
secret_type=secret_type,
|
|
335
|
-
status=RotationStatus.ROLLED_BACK,
|
|
336
|
-
new_key_hash=self._hash_secret(old_value),
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
except Exception as e:
|
|
340
|
-
logger.error("secret.rollback.failed type=%s error=%s", secret_type.value, str(e))
|
|
341
|
-
return RotationResult(
|
|
342
|
-
success=False,
|
|
343
|
-
secret_type=secret_type,
|
|
344
|
-
status=RotationStatus.FAILED,
|
|
345
|
-
error=f"Rollback failed: {type(e).__name__}",
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
def get_due_rotations(self) -> list[RotationSchedule]:
|
|
349
|
-
"""Get list of secrets that are due for rotation."""
|
|
350
|
-
return [s for s in self._schedules.values() if s.is_due()]
|
|
351
|
-
|
|
352
|
-
def get_upcoming_rotations(self, days: int = 30) -> list[RotationSchedule]:
|
|
353
|
-
"""Get list of secrets that will be due within the given days."""
|
|
354
|
-
cutoff = datetime.now(UTC) + timedelta(days=days)
|
|
355
|
-
return [
|
|
356
|
-
s
|
|
357
|
-
for s in self._schedules.values()
|
|
358
|
-
if s.enabled and s.next_rotation and s.next_rotation <= cutoff
|
|
359
|
-
]
|
|
360
|
-
|
|
361
|
-
def get_all_schedules(self) -> list[dict[str, Any]]:
|
|
362
|
-
"""Get all rotation schedules."""
|
|
363
|
-
return [s.to_dict() for s in self._schedules.values()]
|
|
364
|
-
|
|
365
|
-
def check_rotation_health(self) -> dict[str, Any]:
|
|
366
|
-
"""Check overall rotation health."""
|
|
367
|
-
now = datetime.now(UTC)
|
|
368
|
-
overdue = []
|
|
369
|
-
warnings = []
|
|
370
|
-
healthy = []
|
|
371
|
-
|
|
372
|
-
for schedule in self._schedules.values():
|
|
373
|
-
if not schedule.enabled:
|
|
374
|
-
continue
|
|
375
|
-
|
|
376
|
-
if schedule.is_due():
|
|
377
|
-
overdue.append(schedule.secret_type.value)
|
|
378
|
-
elif schedule.should_notify():
|
|
379
|
-
warnings.append(schedule.secret_type.value)
|
|
380
|
-
else:
|
|
381
|
-
healthy.append(schedule.secret_type.value)
|
|
382
|
-
|
|
383
|
-
return {
|
|
384
|
-
"status": "critical" if overdue else ("warning" if warnings else "healthy"),
|
|
385
|
-
"overdue": overdue,
|
|
386
|
-
"warnings": warnings,
|
|
387
|
-
"healthy": healthy,
|
|
388
|
-
"checked_at": now.isoformat(),
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
def _hash_secret(self, secret: str | None) -> str:
|
|
392
|
-
"""Hash a secret for logging (never log actual secrets)."""
|
|
393
|
-
if not secret:
|
|
394
|
-
return ""
|
|
395
|
-
return hashlib.sha256(secret.encode()).hexdigest()[:16]
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def create_secret_rotation(
|
|
399
|
-
state_path: Path | str | None = None,
|
|
400
|
-
) -> SecretRotation:
|
|
401
|
-
"""Create a configured SecretRotation instance."""
|
|
402
|
-
path = None
|
|
403
|
-
if state_path:
|
|
404
|
-
path = Path(state_path)
|
|
405
|
-
elif env_path := os.environ.get("SECRET_ROTATION_STATE"):
|
|
406
|
-
path = Path(env_path)
|
|
407
|
-
|
|
408
|
-
return SecretRotation(state_path=path)
|