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