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.
portal/tenants.py DELETED
@@ -1,340 +0,0 @@
1
- """Tenant management for multi-tenant portal.
2
-
3
- Security controls:
4
- - ASVS V8.4.1: Cross-tenant controls via tenant_id boundary
5
- - ASVS V8.2.2: Data-specific authorization via product/engagement mapping
6
- - ASVS V6.8.2: SAML configuration for enterprise tenants
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import hashlib
12
- import hmac
13
- import json
14
- import logging
15
- import secrets
16
- import string
17
- from dataclasses import asdict, dataclass
18
- from enum import Enum
19
- from pathlib import Path
20
- from typing import TYPE_CHECKING, Any
21
-
22
- if TYPE_CHECKING:
23
- pass
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
- API_KEY_LENGTH = 32
28
- API_KEY_PREFIX = "kek_"
29
-
30
-
31
- class AuthMethod(Enum):
32
- """Authentication methods for tenants."""
33
-
34
- API_KEY = "api_key"
35
- SAML = "saml"
36
- BOTH = "both"
37
-
38
-
39
- @dataclass(frozen=True)
40
- class SAMLTenantConfig:
41
- """SAML configuration for a tenant."""
42
-
43
- entity_id: str
44
- sso_url: str
45
- certificate: str
46
- slo_url: str | None = None
47
- certificate_fingerprint: str | None = None
48
- name_id_format: str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
49
- session_lifetime: int = 28800
50
- role_attribute: str = "role"
51
- default_role: str = "viewer"
52
-
53
- def to_dict(self) -> dict[str, Any]:
54
- return {
55
- "entity_id": self.entity_id,
56
- "sso_url": self.sso_url,
57
- "certificate": self.certificate,
58
- "slo_url": self.slo_url,
59
- "certificate_fingerprint": self.certificate_fingerprint,
60
- "name_id_format": self.name_id_format,
61
- "session_lifetime": self.session_lifetime,
62
- "role_attribute": self.role_attribute,
63
- "default_role": self.default_role,
64
- }
65
-
66
- @classmethod
67
- def from_dict(cls, data: dict[str, Any]) -> SAMLTenantConfig:
68
- return cls(
69
- entity_id=str(data["entity_id"]),
70
- sso_url=str(data["sso_url"]),
71
- certificate=str(data["certificate"]),
72
- slo_url=data.get("slo_url"),
73
- certificate_fingerprint=data.get("certificate_fingerprint"),
74
- name_id_format=data.get(
75
- "name_id_format",
76
- "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
77
- ),
78
- session_lifetime=int(data.get("session_lifetime", 28800)),
79
- role_attribute=str(data.get("role_attribute", "role")),
80
- default_role=str(data.get("default_role", "viewer")),
81
- )
82
-
83
-
84
- @dataclass(frozen=True)
85
- class Tenant:
86
- """Represents a portal tenant with DefectDojo product mapping."""
87
-
88
- id: str
89
- name: str
90
- api_key_hash: str
91
- dojo_product_id: int
92
- dojo_engagement_id: int
93
- enabled: bool = True
94
- max_upload_size_mb: int = 10
95
- auth_method: AuthMethod = AuthMethod.API_KEY
96
- saml_config: SAMLTenantConfig | None = None
97
- license_token: str | None = None
98
- default_role: str = "viewer"
99
-
100
- def to_dict(self) -> dict[str, object]:
101
- result = asdict(self)
102
- result["auth_method"] = self.auth_method.value
103
- if self.saml_config:
104
- result["saml_config"] = self.saml_config.to_dict()
105
- return result
106
-
107
- @classmethod
108
- def from_dict(cls, data: dict[str, object]) -> Tenant:
109
- dojo_product = data["dojo_product_id"]
110
- dojo_engagement = data["dojo_engagement_id"]
111
- max_size = data.get("max_upload_size_mb", 10)
112
-
113
- auth_method_str = data.get("auth_method", "api_key")
114
- auth_method = AuthMethod(str(auth_method_str))
115
-
116
- saml_config = None
117
- saml_data = data.get("saml_config")
118
- if saml_data and isinstance(saml_data, dict):
119
- saml_config = SAMLTenantConfig.from_dict(saml_data)
120
-
121
- return cls(
122
- id=str(data["id"]),
123
- name=str(data["name"]),
124
- api_key_hash=str(data["api_key_hash"]),
125
- dojo_product_id=int(str(dojo_product)),
126
- dojo_engagement_id=int(str(dojo_engagement)),
127
- enabled=bool(data.get("enabled", True)),
128
- max_upload_size_mb=int(str(max_size)),
129
- auth_method=auth_method,
130
- saml_config=saml_config,
131
- license_token=data.get("license_token"), # type: ignore[arg-type]
132
- default_role=str(data.get("default_role", "viewer")),
133
- )
134
-
135
- def is_enterprise(self) -> bool:
136
- """Check if tenant has enterprise features enabled."""
137
- return self.auth_method in (AuthMethod.SAML, AuthMethod.BOTH)
138
-
139
-
140
- def generate_api_key() -> str:
141
- """Generate a secure random API key with prefix."""
142
- alphabet = string.ascii_letters + string.digits
143
- key = "".join(secrets.choice(alphabet) for _ in range(API_KEY_LENGTH))
144
- return f"{API_KEY_PREFIX}{key}"
145
-
146
-
147
- def hash_api_key(api_key: str) -> str:
148
- """Hash an API key using SHA-256 for secure storage."""
149
- return hashlib.sha256(api_key.encode()).hexdigest()
150
-
151
-
152
- def verify_api_key(api_key: str, api_key_hash: str) -> bool:
153
- """Verify an API key against its hash using constant-time comparison."""
154
- computed_hash = hash_api_key(api_key)
155
- return hmac.compare_digest(computed_hash, api_key_hash)
156
-
157
-
158
- class TenantStore:
159
- """File-based tenant storage for MVP.
160
-
161
- Easily swappable for database storage in production.
162
- """
163
-
164
- def __init__(self, store_path: Path) -> None:
165
- self._store_path = store_path
166
- self._tenants: dict[str, Tenant] = {}
167
- self._load()
168
-
169
- def _load(self) -> None:
170
- """Load tenants from storage file."""
171
- if not self._store_path.exists():
172
- self._tenants = {}
173
- return
174
-
175
- try:
176
- data = json.loads(self._store_path.read_text())
177
- self._tenants = {
178
- tid: Tenant.from_dict(tdata) for tid, tdata in data.get("tenants", {}).items()
179
- }
180
- except (json.JSONDecodeError, KeyError, TypeError) as exc:
181
- logger.warning("Failed to load tenant store: %s", exc)
182
- self._tenants = {}
183
-
184
- def _save(self) -> None:
185
- """Persist tenants to storage file."""
186
- self._store_path.parent.mkdir(parents=True, exist_ok=True)
187
- data = {"tenants": {tid: t.to_dict() for tid, t in self._tenants.items()}}
188
- self._store_path.write_text(json.dumps(data, indent=2))
189
-
190
- def get_by_id(self, tenant_id: str) -> Tenant | None:
191
- """Get tenant by ID."""
192
- return self._tenants.get(tenant_id)
193
-
194
- def get_by_api_key(self, api_key: str) -> Tenant | None:
195
- """Find tenant by API key (constant-time for each tenant)."""
196
- for tenant in self._tenants.values():
197
- if verify_api_key(api_key, tenant.api_key_hash):
198
- return tenant
199
- return None
200
-
201
- def create(
202
- self,
203
- tenant_id: str,
204
- name: str,
205
- dojo_product_id: int,
206
- dojo_engagement_id: int,
207
- max_upload_size_mb: int = 10,
208
- auth_method: AuthMethod = AuthMethod.API_KEY,
209
- saml_config: SAMLTenantConfig | None = None,
210
- license_token: str | None = None,
211
- default_role: str = "viewer",
212
- ) -> tuple[Tenant, str]:
213
- """Create a new tenant and return (tenant, api_key).
214
-
215
- The plaintext API key is only returned once during creation.
216
- """
217
- if tenant_id in self._tenants:
218
- raise ValueError(f"Tenant {tenant_id} already exists")
219
-
220
- api_key = generate_api_key()
221
- tenant = Tenant(
222
- id=tenant_id,
223
- name=name,
224
- api_key_hash=hash_api_key(api_key),
225
- dojo_product_id=dojo_product_id,
226
- dojo_engagement_id=dojo_engagement_id,
227
- enabled=True,
228
- max_upload_size_mb=max_upload_size_mb,
229
- auth_method=auth_method,
230
- saml_config=saml_config,
231
- license_token=license_token,
232
- default_role=default_role,
233
- )
234
- self._tenants[tenant_id] = tenant
235
- self._save()
236
-
237
- logger.info("Created tenant: %s", tenant_id)
238
- return tenant, api_key
239
-
240
- def update(self, tenant: Tenant) -> None:
241
- """Update an existing tenant."""
242
- if tenant.id not in self._tenants:
243
- raise ValueError(f"Tenant {tenant.id} does not exist")
244
- self._tenants[tenant.id] = tenant
245
- self._save()
246
- logger.info("Updated tenant: %s", tenant.id)
247
-
248
- def delete(self, tenant_id: str) -> bool:
249
- """Delete a tenant by ID."""
250
- if tenant_id not in self._tenants:
251
- return False
252
- del self._tenants[tenant_id]
253
- self._save()
254
- logger.info("Deleted tenant: %s", tenant_id)
255
- return True
256
-
257
- def list_all(self) -> list[Tenant]:
258
- """List all tenants."""
259
- return list(self._tenants.values())
260
-
261
- def rotate_api_key(self, tenant_id: str) -> str | None:
262
- """Generate a new API key for a tenant."""
263
- tenant = self._tenants.get(tenant_id)
264
- if not tenant:
265
- return None
266
-
267
- new_api_key = generate_api_key()
268
- updated = Tenant(
269
- id=tenant.id,
270
- name=tenant.name,
271
- api_key_hash=hash_api_key(new_api_key),
272
- dojo_product_id=tenant.dojo_product_id,
273
- dojo_engagement_id=tenant.dojo_engagement_id,
274
- enabled=tenant.enabled,
275
- max_upload_size_mb=tenant.max_upload_size_mb,
276
- auth_method=tenant.auth_method,
277
- saml_config=tenant.saml_config,
278
- license_token=tenant.license_token,
279
- default_role=tenant.default_role,
280
- )
281
- self._tenants[tenant_id] = updated
282
- self._save()
283
-
284
- logger.info("Rotated API key for tenant: %s", tenant_id)
285
- return new_api_key
286
-
287
- def update_saml_config(
288
- self,
289
- tenant_id: str,
290
- saml_config: SAMLTenantConfig,
291
- auth_method: AuthMethod = AuthMethod.SAML,
292
- ) -> Tenant | None:
293
- """Update SAML configuration for a tenant."""
294
- tenant = self._tenants.get(tenant_id)
295
- if not tenant:
296
- return None
297
-
298
- updated = Tenant(
299
- id=tenant.id,
300
- name=tenant.name,
301
- api_key_hash=tenant.api_key_hash,
302
- dojo_product_id=tenant.dojo_product_id,
303
- dojo_engagement_id=tenant.dojo_engagement_id,
304
- enabled=tenant.enabled,
305
- max_upload_size_mb=tenant.max_upload_size_mb,
306
- auth_method=auth_method,
307
- saml_config=saml_config,
308
- license_token=tenant.license_token,
309
- default_role=tenant.default_role,
310
- )
311
- self._tenants[tenant_id] = updated
312
- self._save()
313
-
314
- logger.info("Updated SAML config for tenant: %s", tenant_id)
315
- return updated
316
-
317
- def update_license(self, tenant_id: str, license_token: str) -> Tenant | None:
318
- """Update license token for a tenant."""
319
- tenant = self._tenants.get(tenant_id)
320
- if not tenant:
321
- return None
322
-
323
- updated = Tenant(
324
- id=tenant.id,
325
- name=tenant.name,
326
- api_key_hash=tenant.api_key_hash,
327
- dojo_product_id=tenant.dojo_product_id,
328
- dojo_engagement_id=tenant.dojo_engagement_id,
329
- enabled=tenant.enabled,
330
- max_upload_size_mb=tenant.max_upload_size_mb,
331
- auth_method=tenant.auth_method,
332
- saml_config=tenant.saml_config,
333
- license_token=license_token,
334
- default_role=tenant.default_role,
335
- )
336
- self._tenants[tenant_id] = updated
337
- self._save()
338
-
339
- logger.info("Updated license for tenant: %s", tenant_id)
340
- return updated
portal/uploads.py DELETED
@@ -1,259 +0,0 @@
1
- """File upload handling with validation and security controls.
2
-
3
- Security controls:
4
- - ASVS V5.2.2: Validate file uploads (type, content)
5
- - ASVS V5.2.4: Enforce file size limits
6
- - Files stored outside web root in secure temp location
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import hashlib
12
- import json
13
- import logging
14
- import os
15
- import re
16
- import secrets
17
- import tempfile
18
- from dataclasses import dataclass
19
- from pathlib import Path
20
- from typing import TYPE_CHECKING
21
-
22
- from kekkai_core import redact
23
-
24
- if TYPE_CHECKING:
25
- from .tenants import Tenant
26
-
27
- logger = logging.getLogger(__name__)
28
-
29
- ALLOWED_EXTENSIONS = frozenset({".json", ".sarif"})
30
- ALLOWED_CONTENT_TYPES = frozenset(
31
- {
32
- "application/json",
33
- "application/sarif+json",
34
- "application/octet-stream",
35
- }
36
- )
37
- DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024
38
- MIN_FILE_SIZE = 2
39
- FILENAME_PATTERN = re.compile(r"^[\w\-. ]{1,255}$")
40
- UPLOAD_ID_LENGTH = 24
41
-
42
-
43
- @dataclass(frozen=True)
44
- class UploadResult:
45
- """Result of upload validation/processing."""
46
-
47
- success: bool
48
- upload_id: str | None = None
49
- file_path: Path | None = None
50
- file_hash: str | None = None
51
- error: str | None = None
52
-
53
-
54
- @dataclass(frozen=True)
55
- class UploadValidation:
56
- """Validated upload metadata."""
57
-
58
- filename: str
59
- extension: str
60
- content_type: str
61
- size: int
62
- content: bytes
63
-
64
-
65
- def validate_upload(
66
- filename: str | None,
67
- content_type: str | None,
68
- content: bytes,
69
- tenant: Tenant,
70
- ) -> UploadResult:
71
- """Validate an uploaded file.
72
-
73
- Args:
74
- filename: Original filename
75
- content_type: MIME type from request
76
- content: File content bytes
77
- tenant: Tenant performing upload (for size limits)
78
-
79
- Returns:
80
- UploadResult with error if validation fails
81
- """
82
- if not filename:
83
- return UploadResult(success=False, error="Missing filename")
84
-
85
- safe_filename = _sanitize_filename(filename)
86
- if not safe_filename:
87
- logger.warning("upload.invalid_filename original=%s", redact(filename))
88
- return UploadResult(success=False, error="Invalid filename")
89
-
90
- extension = _get_extension(safe_filename)
91
- if extension not in ALLOWED_EXTENSIONS:
92
- logger.warning(
93
- "upload.invalid_extension filename=%s extension=%s",
94
- redact(safe_filename),
95
- extension,
96
- )
97
- return UploadResult(
98
- success=False,
99
- error=f"Invalid file type. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
100
- )
101
-
102
- max_size = tenant.max_upload_size_mb * 1024 * 1024
103
- if len(content) > max_size:
104
- logger.warning(
105
- "upload.size_exceeded tenant=%s size=%d max=%d",
106
- tenant.id,
107
- len(content),
108
- max_size,
109
- )
110
- return UploadResult(
111
- success=False,
112
- error=f"File too large. Maximum: {tenant.max_upload_size_mb}MB",
113
- )
114
-
115
- if len(content) < MIN_FILE_SIZE:
116
- return UploadResult(success=False, error="File is empty or too small")
117
-
118
- if not _validate_json_content(content):
119
- logger.warning(
120
- "upload.invalid_json tenant=%s filename=%s", tenant.id, redact(safe_filename)
121
- )
122
- return UploadResult(success=False, error="Invalid JSON content")
123
-
124
- return UploadResult(success=True)
125
-
126
-
127
- def process_upload(
128
- filename: str,
129
- content: bytes,
130
- tenant: Tenant,
131
- upload_dir: Path | None = None,
132
- ) -> UploadResult:
133
- """Process and store a validated upload.
134
-
135
- Files are stored outside web root in a secure temp directory.
136
-
137
- Args:
138
- filename: Sanitized filename
139
- content: Validated file content
140
- tenant: Tenant performing upload
141
- upload_dir: Override upload directory (for testing)
142
-
143
- Returns:
144
- UploadResult with file path and hash if successful
145
- """
146
- validation = validate_upload(filename, None, content, tenant)
147
- if not validation.success:
148
- return validation
149
-
150
- safe_filename = _sanitize_filename(filename)
151
- if not safe_filename:
152
- return UploadResult(success=False, error="Invalid filename")
153
-
154
- upload_id = _generate_upload_id()
155
- file_hash = hashlib.sha256(content).hexdigest()
156
-
157
- base_dir = upload_dir or _get_upload_dir()
158
- tenant_dir = base_dir / tenant.id
159
- tenant_dir.mkdir(parents=True, exist_ok=True)
160
-
161
- extension = _get_extension(safe_filename)
162
- stored_filename = f"{upload_id}{extension}"
163
- file_path = tenant_dir / stored_filename
164
-
165
- file_path.write_bytes(content)
166
- os.chmod(file_path, 0o600)
167
-
168
- logger.info(
169
- "upload.stored tenant=%s upload_id=%s hash=%s size=%d",
170
- tenant.id,
171
- upload_id,
172
- file_hash[:16],
173
- len(content),
174
- )
175
-
176
- return UploadResult(
177
- success=True,
178
- upload_id=upload_id,
179
- file_path=file_path,
180
- file_hash=file_hash,
181
- )
182
-
183
-
184
- def get_upload_path(tenant: Tenant, upload_id: str, upload_dir: Path | None = None) -> Path | None:
185
- """Get the path to an upload file for a specific tenant.
186
-
187
- Enforces tenant boundary - only returns path if upload belongs to tenant.
188
- """
189
- if not _is_valid_upload_id(upload_id):
190
- return None
191
-
192
- base_dir = upload_dir or _get_upload_dir()
193
- tenant_dir = base_dir / tenant.id
194
-
195
- for ext in ALLOWED_EXTENSIONS:
196
- file_path = tenant_dir / f"{upload_id}{ext}"
197
- if file_path.exists() and file_path.is_file():
198
- resolved = file_path.resolve()
199
- if resolved.is_relative_to(tenant_dir.resolve()):
200
- return resolved
201
- return None
202
-
203
-
204
- def delete_upload(tenant: Tenant, upload_id: str, upload_dir: Path | None = None) -> bool:
205
- """Delete an upload file."""
206
- file_path = get_upload_path(tenant, upload_id, upload_dir)
207
- if file_path and file_path.exists():
208
- file_path.unlink()
209
- logger.info("upload.deleted tenant=%s upload_id=%s", tenant.id, upload_id)
210
- return True
211
- return False
212
-
213
-
214
- def _sanitize_filename(filename: str) -> str | None:
215
- """Sanitize filename to prevent path traversal."""
216
- basename = Path(filename).name
217
- if not basename or ".." in basename:
218
- return None
219
- basename = basename.replace("\x00", "")
220
- if not FILENAME_PATTERN.match(basename):
221
- cleaned = re.sub(r"[^\w\-. ]", "_", basename)
222
- if not cleaned or len(cleaned) > 255:
223
- return None
224
- return cleaned
225
- return basename
226
-
227
-
228
- def _get_extension(filename: str) -> str:
229
- """Get lowercase file extension."""
230
- return Path(filename).suffix.lower()
231
-
232
-
233
- def _validate_json_content(content: bytes) -> bool:
234
- """Validate that content is valid JSON."""
235
- try:
236
- json.loads(content.decode("utf-8"))
237
- return True
238
- except (json.JSONDecodeError, UnicodeDecodeError):
239
- return False
240
-
241
-
242
- def _generate_upload_id() -> str:
243
- """Generate a secure random upload ID."""
244
- return secrets.token_urlsafe(UPLOAD_ID_LENGTH)[:UPLOAD_ID_LENGTH]
245
-
246
-
247
- def _is_valid_upload_id(upload_id: str) -> bool:
248
- """Validate upload ID format."""
249
- if not upload_id or len(upload_id) != UPLOAD_ID_LENGTH:
250
- return False
251
- return all(c.isalnum() or c in "-_" for c in upload_id)
252
-
253
-
254
- def _get_upload_dir() -> Path:
255
- """Get the secure upload directory outside web root."""
256
- base = os.environ.get("PORTAL_UPLOAD_DIR")
257
- if base:
258
- return Path(base)
259
- return Path(tempfile.gettempdir()) / "kekkai-portal-uploads"