kekkai-cli 1.1.0__py3-none-any.whl → 2.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.
- kekkai/cli.py +238 -36
- kekkai/dojo_import.py +9 -1
- kekkai/output.py +2 -3
- kekkai/report/unified.py +226 -0
- kekkai/triage/__init__.py +54 -1
- kekkai/triage/fix_screen.py +232 -0
- kekkai/triage/loader.py +196 -0
- kekkai/triage/screens.py +1 -0
- kekkai_cli-2.0.0.dist-info/METADATA +317 -0
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/RECORD +13 -28
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/entry_points.txt +0 -1
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/top_level.txt +0 -1
- kekkai_cli-1.1.0.dist-info/METADATA +0 -359
- 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-2.0.0.dist-info}/WHEEL +0 -0
portal/web.py
DELETED
|
@@ -1,393 +0,0 @@
|
|
|
1
|
-
"""Portal WSGI web application with Kekkai theming.
|
|
2
|
-
|
|
3
|
-
Provides:
|
|
4
|
-
- Upload API endpoint (POST /api/v1/upload)
|
|
5
|
-
- Dashboard with Kekkai branding
|
|
6
|
-
- Static asset serving
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import logging
|
|
13
|
-
import os
|
|
14
|
-
import re
|
|
15
|
-
from collections.abc import Callable, Iterable
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
from typing import Any, BinaryIO, cast
|
|
18
|
-
|
|
19
|
-
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
20
|
-
|
|
21
|
-
from .api import get_tenant_info, get_tenant_stats, list_uploads
|
|
22
|
-
from .auth import authenticate_request
|
|
23
|
-
from .tenants import Tenant, TenantStore
|
|
24
|
-
from .uploads import process_upload, validate_upload
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
from .enterprise import ENTERPRISE_AVAILABLE
|
|
28
|
-
from .enterprise import rbac as enterprise_rbac
|
|
29
|
-
from .enterprise import saml as enterprise_saml
|
|
30
|
-
except ImportError:
|
|
31
|
-
ENTERPRISE_AVAILABLE = False
|
|
32
|
-
enterprise_saml = None # type: ignore[assignment]
|
|
33
|
-
enterprise_rbac = None # type: ignore[assignment]
|
|
34
|
-
|
|
35
|
-
logger = logging.getLogger(__name__)
|
|
36
|
-
|
|
37
|
-
Environ = dict[str, Any]
|
|
38
|
-
StartResponse = Callable[[str, list[tuple[str, str]]], Callable[[bytes], Any]]
|
|
39
|
-
|
|
40
|
-
STATIC_DIR = Path(__file__).parent / "static"
|
|
41
|
-
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
42
|
-
|
|
43
|
-
SECURE_HEADERS = [
|
|
44
|
-
("X-Content-Type-Options", "nosniff"),
|
|
45
|
-
("X-Frame-Options", "DENY"),
|
|
46
|
-
("X-XSS-Protection", "1; mode=block"),
|
|
47
|
-
("Referrer-Policy", "strict-origin-when-cross-origin"),
|
|
48
|
-
(
|
|
49
|
-
"Content-Security-Policy",
|
|
50
|
-
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
|
|
51
|
-
),
|
|
52
|
-
]
|
|
53
|
-
|
|
54
|
-
MULTIPART_BOUNDARY_PATTERN = re.compile(r"boundary=([^\s;]+)", re.IGNORECASE)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class PortalApp:
|
|
58
|
-
"""Kekkai Portal WSGI Application."""
|
|
59
|
-
|
|
60
|
-
def __init__(self, tenant_store: TenantStore) -> None:
|
|
61
|
-
self._tenant_store = tenant_store
|
|
62
|
-
self._jinja_env = Environment(
|
|
63
|
-
loader=FileSystemLoader(TEMPLATES_DIR),
|
|
64
|
-
autoescape=select_autoescape(["html", "xml"]),
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
def __call__(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
|
|
68
|
-
path = str(environ.get("PATH_INFO", "/"))
|
|
69
|
-
method = str(environ.get("REQUEST_METHOD", "GET"))
|
|
70
|
-
|
|
71
|
-
if path.startswith("/static/"):
|
|
72
|
-
return self._serve_static(path, start_response)
|
|
73
|
-
|
|
74
|
-
if path == "/api/v1/upload" and method == "POST":
|
|
75
|
-
return self._handle_upload(environ, start_response)
|
|
76
|
-
|
|
77
|
-
if path == "/api/v1/health":
|
|
78
|
-
return self._handle_health(start_response)
|
|
79
|
-
|
|
80
|
-
if path == "/api/v1/tenant/info" and method == "GET":
|
|
81
|
-
return self._handle_tenant_info(environ, start_response)
|
|
82
|
-
|
|
83
|
-
if path == "/api/v1/uploads" and method == "GET":
|
|
84
|
-
return self._handle_list_uploads(environ, start_response)
|
|
85
|
-
|
|
86
|
-
if path == "/api/v1/stats" and method == "GET":
|
|
87
|
-
return self._handle_stats(environ, start_response)
|
|
88
|
-
|
|
89
|
-
if path == "/" and method == "GET":
|
|
90
|
-
return self._serve_dashboard(environ, start_response)
|
|
91
|
-
|
|
92
|
-
return self._not_found(start_response)
|
|
93
|
-
|
|
94
|
-
def _handle_upload(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
|
|
95
|
-
"""Handle file upload with authentication and validation."""
|
|
96
|
-
client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
|
|
97
|
-
headers = _extract_headers(environ)
|
|
98
|
-
|
|
99
|
-
auth_result = authenticate_request(headers, self._tenant_store, client_ip)
|
|
100
|
-
if not auth_result.authenticated or not auth_result.tenant:
|
|
101
|
-
return self._unauthorized(start_response, auth_result.error or "Unauthorized")
|
|
102
|
-
|
|
103
|
-
tenant = auth_result.tenant
|
|
104
|
-
|
|
105
|
-
content_type = headers.get("content-type", "")
|
|
106
|
-
content_length = int(environ.get("CONTENT_LENGTH", 0) or 0)
|
|
107
|
-
|
|
108
|
-
if content_length > tenant.max_upload_size_mb * 1024 * 1024:
|
|
109
|
-
return self._error_response(
|
|
110
|
-
start_response,
|
|
111
|
-
413,
|
|
112
|
-
f"File too large. Maximum: {tenant.max_upload_size_mb}MB",
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
input_stream = environ.get("wsgi.input")
|
|
116
|
-
if not input_stream or content_length == 0:
|
|
117
|
-
return self._error_response(start_response, 400, "No file provided")
|
|
118
|
-
|
|
119
|
-
body = cast(BinaryIO, input_stream).read(content_length)
|
|
120
|
-
|
|
121
|
-
if "multipart/form-data" in content_type:
|
|
122
|
-
filename, file_content = _parse_multipart(body, content_type)
|
|
123
|
-
else:
|
|
124
|
-
filename = headers.get("x-filename", "upload.json")
|
|
125
|
-
file_content = body
|
|
126
|
-
|
|
127
|
-
if not filename or not file_content:
|
|
128
|
-
return self._error_response(start_response, 400, "Invalid upload")
|
|
129
|
-
|
|
130
|
-
validation = validate_upload(filename, content_type, file_content, tenant)
|
|
131
|
-
if not validation.success:
|
|
132
|
-
return self._error_response(
|
|
133
|
-
start_response, 400, validation.error or "Validation failed"
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
result = process_upload(filename, file_content, tenant)
|
|
137
|
-
if not result.success:
|
|
138
|
-
return self._error_response(start_response, 500, result.error or "Upload failed")
|
|
139
|
-
|
|
140
|
-
logger.info(
|
|
141
|
-
"upload.complete tenant=%s upload_id=%s",
|
|
142
|
-
tenant.id,
|
|
143
|
-
result.upload_id,
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
response_data = {
|
|
147
|
-
"success": True,
|
|
148
|
-
"upload_id": result.upload_id,
|
|
149
|
-
"file_hash": result.file_hash,
|
|
150
|
-
"tenant_id": tenant.id,
|
|
151
|
-
"dojo_product_id": tenant.dojo_product_id,
|
|
152
|
-
"dojo_engagement_id": tenant.dojo_engagement_id,
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return self._json_response(start_response, 200, response_data)
|
|
156
|
-
|
|
157
|
-
def _handle_health(self, start_response: StartResponse) -> Iterable[bytes]:
|
|
158
|
-
"""Health check endpoint."""
|
|
159
|
-
return self._json_response(start_response, 200, {"status": "healthy"})
|
|
160
|
-
|
|
161
|
-
def _handle_tenant_info(
|
|
162
|
-
self, environ: Environ, start_response: StartResponse
|
|
163
|
-
) -> Iterable[bytes]:
|
|
164
|
-
"""Get current tenant information."""
|
|
165
|
-
headers = _extract_headers(environ)
|
|
166
|
-
client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
|
|
167
|
-
|
|
168
|
-
auth_result = authenticate_request(headers, self._tenant_store, client_ip)
|
|
169
|
-
if not auth_result.authenticated or not auth_result.tenant:
|
|
170
|
-
return self._unauthorized(start_response, auth_result.error or "Unauthorized")
|
|
171
|
-
|
|
172
|
-
tenant_info = get_tenant_info(auth_result.tenant)
|
|
173
|
-
return self._json_response(start_response, 200, tenant_info)
|
|
174
|
-
|
|
175
|
-
def _handle_list_uploads(
|
|
176
|
-
self, environ: Environ, start_response: StartResponse
|
|
177
|
-
) -> Iterable[bytes]:
|
|
178
|
-
"""List recent uploads for authenticated tenant."""
|
|
179
|
-
headers = _extract_headers(environ)
|
|
180
|
-
client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
|
|
181
|
-
|
|
182
|
-
auth_result = authenticate_request(headers, self._tenant_store, client_ip)
|
|
183
|
-
if not auth_result.authenticated or not auth_result.tenant:
|
|
184
|
-
return self._unauthorized(start_response, auth_result.error or "Unauthorized")
|
|
185
|
-
|
|
186
|
-
# Parse limit parameter from query string
|
|
187
|
-
query_string = str(environ.get("QUERY_STRING", ""))
|
|
188
|
-
limit = 50
|
|
189
|
-
if "limit=" in query_string:
|
|
190
|
-
try:
|
|
191
|
-
limit_str = query_string.split("limit=")[1].split("&")[0]
|
|
192
|
-
limit = min(int(limit_str), 100) # Cap at 100
|
|
193
|
-
except (ValueError, IndexError):
|
|
194
|
-
pass
|
|
195
|
-
|
|
196
|
-
uploads = list_uploads(auth_result.tenant, limit)
|
|
197
|
-
return self._json_response(start_response, 200, {"uploads": uploads})
|
|
198
|
-
|
|
199
|
-
def _handle_stats(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
|
|
200
|
-
"""Get statistics for authenticated tenant."""
|
|
201
|
-
headers = _extract_headers(environ)
|
|
202
|
-
client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
|
|
203
|
-
|
|
204
|
-
auth_result = authenticate_request(headers, self._tenant_store, client_ip)
|
|
205
|
-
if not auth_result.authenticated or not auth_result.tenant:
|
|
206
|
-
return self._unauthorized(start_response, auth_result.error or "Unauthorized")
|
|
207
|
-
|
|
208
|
-
stats = get_tenant_stats(auth_result.tenant)
|
|
209
|
-
return self._json_response(start_response, 200, stats)
|
|
210
|
-
|
|
211
|
-
def _serve_dashboard(self, environ: Environ, start_response: StartResponse) -> Iterable[bytes]:
|
|
212
|
-
"""Serve the Kekkai-themed dashboard."""
|
|
213
|
-
headers = _extract_headers(environ)
|
|
214
|
-
client_ip = str(environ.get("REMOTE_ADDR", "unknown"))
|
|
215
|
-
|
|
216
|
-
auth_result = authenticate_request(headers, self._tenant_store, client_ip)
|
|
217
|
-
tenant = auth_result.tenant if auth_result.authenticated else None
|
|
218
|
-
|
|
219
|
-
content = self._render_template(tenant)
|
|
220
|
-
response_headers = [("Content-Type", "text/html; charset=utf-8")] + SECURE_HEADERS
|
|
221
|
-
start_response("200 OK", response_headers)
|
|
222
|
-
return [content.encode("utf-8")]
|
|
223
|
-
|
|
224
|
-
def _render_template(self, tenant: Tenant | None) -> str:
|
|
225
|
-
"""Render dashboard or login template based on authentication."""
|
|
226
|
-
if tenant:
|
|
227
|
-
template = self._jinja_env.get_template("dashboard.html")
|
|
228
|
-
return str(template.render(tenant=tenant.to_dict()))
|
|
229
|
-
else:
|
|
230
|
-
template = self._jinja_env.get_template("login.html")
|
|
231
|
-
return str(template.render())
|
|
232
|
-
|
|
233
|
-
def _serve_static(self, path: str, start_response: StartResponse) -> Iterable[bytes]:
|
|
234
|
-
"""Serve static assets with security checks."""
|
|
235
|
-
relative_path = path.removeprefix("/static/")
|
|
236
|
-
if ".." in relative_path or relative_path.startswith("/"):
|
|
237
|
-
return self._not_found(start_response)
|
|
238
|
-
|
|
239
|
-
file_path = STATIC_DIR / relative_path
|
|
240
|
-
try:
|
|
241
|
-
resolved = file_path.resolve()
|
|
242
|
-
if not resolved.is_relative_to(STATIC_DIR.resolve()):
|
|
243
|
-
return self._not_found(start_response)
|
|
244
|
-
except (ValueError, OSError):
|
|
245
|
-
return self._not_found(start_response)
|
|
246
|
-
|
|
247
|
-
if not file_path.exists() or not file_path.is_file():
|
|
248
|
-
return self._not_found(start_response)
|
|
249
|
-
|
|
250
|
-
content_type = _get_content_type(file_path.suffix)
|
|
251
|
-
content = file_path.read_bytes()
|
|
252
|
-
|
|
253
|
-
response_headers = [("Content-Type", content_type)] + SECURE_HEADERS
|
|
254
|
-
start_response("200 OK", response_headers)
|
|
255
|
-
return [content]
|
|
256
|
-
|
|
257
|
-
def _json_response(
|
|
258
|
-
self,
|
|
259
|
-
start_response: StartResponse,
|
|
260
|
-
status_code: int,
|
|
261
|
-
data: dict[str, Any],
|
|
262
|
-
) -> Iterable[bytes]:
|
|
263
|
-
"""Send a JSON response."""
|
|
264
|
-
status = f"{status_code} {'OK' if status_code == 200 else 'Error'}"
|
|
265
|
-
response_headers = [("Content-Type", "application/json")] + SECURE_HEADERS
|
|
266
|
-
start_response(status, response_headers)
|
|
267
|
-
return [json.dumps(data).encode("utf-8")]
|
|
268
|
-
|
|
269
|
-
def _error_response(
|
|
270
|
-
self,
|
|
271
|
-
start_response: StartResponse,
|
|
272
|
-
status_code: int,
|
|
273
|
-
message: str,
|
|
274
|
-
) -> Iterable[bytes]:
|
|
275
|
-
"""Send an error response."""
|
|
276
|
-
return self._json_response(
|
|
277
|
-
start_response,
|
|
278
|
-
status_code,
|
|
279
|
-
{"success": False, "error": message},
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
def _unauthorized(self, start_response: StartResponse, message: str) -> Iterable[bytes]:
|
|
283
|
-
"""Send 401 Unauthorized response."""
|
|
284
|
-
response_headers = [
|
|
285
|
-
("Content-Type", "application/json"),
|
|
286
|
-
("WWW-Authenticate", "Bearer"),
|
|
287
|
-
] + SECURE_HEADERS
|
|
288
|
-
start_response("401 Unauthorized", response_headers)
|
|
289
|
-
return [json.dumps({"success": False, "error": message}).encode("utf-8")]
|
|
290
|
-
|
|
291
|
-
def _not_found(self, start_response: StartResponse) -> Iterable[bytes]:
|
|
292
|
-
"""Send 404 Not Found response."""
|
|
293
|
-
return self._error_response(start_response, 404, "Not found")
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def create_app(tenant_store_path: Path | None = None) -> PortalApp:
|
|
297
|
-
"""Create a configured PortalApp instance."""
|
|
298
|
-
store_path = tenant_store_path or Path(
|
|
299
|
-
os.environ.get("PORTAL_TENANT_STORE", "/var/lib/kekkai-portal/tenants.json")
|
|
300
|
-
)
|
|
301
|
-
tenant_store = TenantStore(store_path)
|
|
302
|
-
return PortalApp(tenant_store)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def main() -> int:
|
|
306
|
-
"""Run the portal development server."""
|
|
307
|
-
from wsgiref.simple_server import make_server
|
|
308
|
-
|
|
309
|
-
host = os.environ.get("PORTAL_HOST", "127.0.0.1")
|
|
310
|
-
port = int(os.environ.get("PORTAL_PORT", "8000"))
|
|
311
|
-
tenant_store = os.environ.get("PORTAL_TENANT_STORE")
|
|
312
|
-
|
|
313
|
-
store_path = Path(tenant_store) if tenant_store else None
|
|
314
|
-
app = create_app(store_path)
|
|
315
|
-
|
|
316
|
-
print(f"Starting Kekkai Portal on http://{host}:{port}")
|
|
317
|
-
print("Press Ctrl+C to stop")
|
|
318
|
-
|
|
319
|
-
with make_server(host, port, app) as httpd:
|
|
320
|
-
try:
|
|
321
|
-
httpd.serve_forever()
|
|
322
|
-
except KeyboardInterrupt:
|
|
323
|
-
print("\nShutting down...")
|
|
324
|
-
|
|
325
|
-
return 0
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if __name__ == "__main__":
|
|
329
|
-
import sys
|
|
330
|
-
|
|
331
|
-
sys.exit(main())
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _extract_headers(environ: Environ) -> dict[str, str]:
|
|
335
|
-
"""Extract HTTP headers from WSGI environ."""
|
|
336
|
-
headers: dict[str, str] = {}
|
|
337
|
-
for key, value in environ.items():
|
|
338
|
-
if key.startswith("HTTP_"):
|
|
339
|
-
header_name = key[5:].replace("_", "-").lower()
|
|
340
|
-
headers[header_name] = str(value)
|
|
341
|
-
elif key == "CONTENT_TYPE":
|
|
342
|
-
headers["content-type"] = str(value)
|
|
343
|
-
elif key == "CONTENT_LENGTH":
|
|
344
|
-
headers["content-length"] = str(value)
|
|
345
|
-
return headers
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
def _parse_multipart(body: bytes, content_type: str) -> tuple[str | None, bytes | None]:
|
|
349
|
-
"""Parse multipart form data to extract file."""
|
|
350
|
-
match = MULTIPART_BOUNDARY_PATTERN.search(content_type)
|
|
351
|
-
if not match:
|
|
352
|
-
return None, None
|
|
353
|
-
|
|
354
|
-
boundary = match.group(1).encode()
|
|
355
|
-
if boundary.startswith(b'"') and boundary.endswith(b'"'):
|
|
356
|
-
boundary = boundary[1:-1]
|
|
357
|
-
|
|
358
|
-
parts = body.split(b"--" + boundary)
|
|
359
|
-
for part in parts:
|
|
360
|
-
if b"Content-Disposition" not in part:
|
|
361
|
-
continue
|
|
362
|
-
|
|
363
|
-
header_end = part.find(b"\r\n\r\n")
|
|
364
|
-
if header_end == -1:
|
|
365
|
-
continue
|
|
366
|
-
|
|
367
|
-
headers_raw = part[:header_end].decode("utf-8", errors="replace")
|
|
368
|
-
content = part[header_end + 4 :]
|
|
369
|
-
|
|
370
|
-
if content.endswith(b"\r\n"):
|
|
371
|
-
content = content[:-2]
|
|
372
|
-
|
|
373
|
-
filename_match = re.search(r'filename="([^"]+)"', headers_raw)
|
|
374
|
-
if filename_match:
|
|
375
|
-
return filename_match.group(1), content
|
|
376
|
-
|
|
377
|
-
return None, None
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def _get_content_type(extension: str) -> str:
|
|
381
|
-
"""Get MIME type for file extension."""
|
|
382
|
-
types = {
|
|
383
|
-
".css": "text/css",
|
|
384
|
-
".js": "application/javascript",
|
|
385
|
-
".png": "image/png",
|
|
386
|
-
".jpg": "image/jpeg",
|
|
387
|
-
".jpeg": "image/jpeg",
|
|
388
|
-
".svg": "image/svg+xml",
|
|
389
|
-
".ico": "image/x-icon",
|
|
390
|
-
".woff": "font/woff",
|
|
391
|
-
".woff2": "font/woff2",
|
|
392
|
-
}
|
|
393
|
-
return types.get(extension.lower(), "application/octet-stream")
|
|
File without changes
|