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
kekkai/fix/differ.py ADDED
@@ -0,0 +1,427 @@
1
+ """Diff generation and application for code fixes.
2
+
3
+ Handles:
4
+ - Parsing unified diff format from LLM output
5
+ - Validating diffs before application
6
+ - Applying diffs safely with backup
7
+ - Generating preview output
8
+
9
+ ASVS V5.3.3: Output encoding preserves code intent.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import shutil
17
+ import tempfile
18
+ from dataclasses import dataclass, field
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import ClassVar
22
+
23
+
24
+ @dataclass
25
+ class DiffHunk:
26
+ """Represents a single hunk in a unified diff."""
27
+
28
+ old_start: int
29
+ old_count: int
30
+ new_start: int
31
+ new_count: int
32
+ lines: list[str] = field(default_factory=list)
33
+
34
+ def to_string(self) -> str:
35
+ """Convert hunk back to unified diff format."""
36
+ header = f"@@ -{self.old_start},{self.old_count} +{self.new_start},{self.new_count} @@"
37
+ return header + "\n" + "\n".join(self.lines)
38
+
39
+
40
+ @dataclass
41
+ class ParsedDiff:
42
+ """Represents a parsed unified diff."""
43
+
44
+ original_file: str
45
+ modified_file: str
46
+ hunks: list[DiffHunk] = field(default_factory=list)
47
+ raw_diff: str = ""
48
+
49
+ @property
50
+ def is_valid(self) -> bool:
51
+ """Check if the diff has required components."""
52
+ return bool(self.original_file and self.hunks)
53
+
54
+ def to_string(self) -> str:
55
+ """Convert back to unified diff format."""
56
+ lines = [
57
+ f"--- {self.original_file}",
58
+ f"+++ {self.modified_file}",
59
+ ]
60
+ for hunk in self.hunks:
61
+ lines.append(hunk.to_string())
62
+ return "\n".join(lines)
63
+
64
+
65
+ @dataclass
66
+ class ApplyResult:
67
+ """Result of applying a diff."""
68
+
69
+ success: bool
70
+ file_path: str
71
+ backup_path: str | None = None
72
+ error: str | None = None
73
+ lines_added: int = 0
74
+ lines_removed: int = 0
75
+
76
+
77
+ class DiffParser:
78
+ """Parses unified diff format from LLM output."""
79
+
80
+ HUNK_HEADER: ClassVar[re.Pattern[str]] = re.compile(
81
+ r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@"
82
+ )
83
+
84
+ def parse(self, diff_text: str) -> ParsedDiff:
85
+ """Parse unified diff text into structured format.
86
+
87
+ Args:
88
+ diff_text: Raw diff text (possibly with surrounding content)
89
+
90
+ Returns:
91
+ ParsedDiff object with parsed hunks
92
+ """
93
+ # Clean up LLM output - remove markdown fences if present
94
+ cleaned = self._clean_llm_output(diff_text)
95
+ lines = cleaned.splitlines()
96
+
97
+ original_file = ""
98
+ modified_file = ""
99
+ hunks: list[DiffHunk] = []
100
+ current_hunk: DiffHunk | None = None
101
+
102
+ for line in lines:
103
+ if line.startswith("--- "):
104
+ original_file = line[4:].strip()
105
+ # Handle timestamps in diff headers
106
+ if "\t" in original_file:
107
+ original_file = original_file.split("\t")[0]
108
+ elif line.startswith("+++ "):
109
+ modified_file = line[4:].strip()
110
+ if "\t" in modified_file:
111
+ modified_file = modified_file.split("\t")[0]
112
+ elif match := self.HUNK_HEADER.match(line):
113
+ if current_hunk:
114
+ hunks.append(current_hunk)
115
+ current_hunk = DiffHunk(
116
+ old_start=int(match.group(1)),
117
+ old_count=int(match.group(2) or 1),
118
+ new_start=int(match.group(3)),
119
+ new_count=int(match.group(4) or 1),
120
+ )
121
+ elif current_hunk is not None:
122
+ is_diff_line = (
123
+ line.startswith("+")
124
+ or line.startswith("-")
125
+ or line.startswith(" ")
126
+ or line == "\"
127
+ )
128
+ if is_diff_line:
129
+ current_hunk.lines.append(line)
130
+
131
+ if current_hunk:
132
+ hunks.append(current_hunk)
133
+
134
+ return ParsedDiff(
135
+ original_file=original_file,
136
+ modified_file=modified_file,
137
+ hunks=hunks,
138
+ raw_diff=cleaned,
139
+ )
140
+
141
+ def _clean_llm_output(self, text: str) -> str:
142
+ """Remove markdown code fences and other LLM artifacts."""
143
+ # Remove ```diff or ``` markers
144
+ text = re.sub(r"^```(?:diff)?\s*\n", "", text, flags=re.MULTILINE)
145
+ text = re.sub(r"\n```\s*$", "", text)
146
+ text = text.strip()
147
+ return text
148
+
149
+
150
+ class DiffApplier:
151
+ """Applies unified diffs to files safely."""
152
+
153
+ def __init__(self, backup_dir: Path | None = None) -> None:
154
+ """Initialize applier with optional backup directory.
155
+
156
+ Args:
157
+ backup_dir: Directory for backups. If None, uses temp dir.
158
+ """
159
+ self._backup_dir = backup_dir
160
+
161
+ def validate(self, diff: ParsedDiff, repo_path: Path) -> tuple[bool, str]:
162
+ """Validate that a diff can be applied.
163
+
164
+ Args:
165
+ diff: Parsed diff to validate
166
+ repo_path: Base path of the repository
167
+
168
+ Returns:
169
+ Tuple of (is_valid, error_message)
170
+ """
171
+ if not diff.is_valid:
172
+ return False, "Invalid diff: missing file path or hunks"
173
+
174
+ # Resolve file path
175
+ file_path = self._resolve_file_path(diff.original_file, repo_path)
176
+ if not file_path:
177
+ return False, f"Cannot resolve file path: {diff.original_file}"
178
+
179
+ if not file_path.exists():
180
+ return False, f"File not found: {file_path}"
181
+
182
+ if not file_path.is_file():
183
+ return False, f"Not a regular file: {file_path}"
184
+
185
+ # Check file is readable
186
+ try:
187
+ content = file_path.read_text()
188
+ except (OSError, UnicodeDecodeError) as e:
189
+ return False, f"Cannot read file: {e}"
190
+
191
+ # Validate hunks can be applied
192
+ lines = content.splitlines(keepends=True)
193
+ for i, hunk in enumerate(diff.hunks):
194
+ valid, err = self._validate_hunk(hunk, lines, i + 1)
195
+ if not valid:
196
+ return False, err
197
+
198
+ return True, ""
199
+
200
+ def apply(
201
+ self,
202
+ diff: ParsedDiff,
203
+ repo_path: Path,
204
+ *,
205
+ dry_run: bool = False,
206
+ create_backup: bool = True,
207
+ ) -> ApplyResult:
208
+ """Apply a diff to the target file.
209
+
210
+ Args:
211
+ diff: Parsed diff to apply
212
+ repo_path: Base path of the repository
213
+ dry_run: If True, don't actually modify the file
214
+ create_backup: If True, create backup before modifying
215
+
216
+ Returns:
217
+ ApplyResult with status and details
218
+ """
219
+ file_path = self._resolve_file_path(diff.original_file, repo_path)
220
+ if not file_path:
221
+ return ApplyResult(
222
+ success=False,
223
+ file_path=diff.original_file,
224
+ error=f"Cannot resolve path: {diff.original_file}",
225
+ )
226
+
227
+ # Validate first
228
+ valid, err = self.validate(diff, repo_path)
229
+ if not valid:
230
+ return ApplyResult(
231
+ success=False,
232
+ file_path=str(file_path),
233
+ error=err,
234
+ )
235
+
236
+ try:
237
+ original_content = file_path.read_text()
238
+ except (OSError, UnicodeDecodeError) as e:
239
+ return ApplyResult(
240
+ success=False,
241
+ file_path=str(file_path),
242
+ error=f"Cannot read file: {e}",
243
+ )
244
+
245
+ # Apply hunks
246
+ try:
247
+ new_content, stats = self._apply_hunks(original_content, diff.hunks)
248
+ except ValueError as e:
249
+ return ApplyResult(
250
+ success=False,
251
+ file_path=str(file_path),
252
+ error=f"Failed to apply hunks: {e}",
253
+ )
254
+
255
+ if dry_run:
256
+ return ApplyResult(
257
+ success=True,
258
+ file_path=str(file_path),
259
+ lines_added=stats["added"],
260
+ lines_removed=stats["removed"],
261
+ )
262
+
263
+ # Create backup
264
+ backup_path = None
265
+ if create_backup:
266
+ backup_path = self._create_backup(file_path)
267
+
268
+ # Write new content
269
+ try:
270
+ file_path.write_text(new_content)
271
+ except OSError as e:
272
+ # Restore from backup if write fails
273
+ if backup_path:
274
+ shutil.copy2(backup_path, file_path)
275
+ return ApplyResult(
276
+ success=False,
277
+ file_path=str(file_path),
278
+ backup_path=str(backup_path) if backup_path else None,
279
+ error=f"Failed to write file: {e}",
280
+ )
281
+
282
+ return ApplyResult(
283
+ success=True,
284
+ file_path=str(file_path),
285
+ backup_path=str(backup_path) if backup_path else None,
286
+ lines_added=stats["added"],
287
+ lines_removed=stats["removed"],
288
+ )
289
+
290
+ def preview(self, diff: ParsedDiff, repo_path: Path) -> str:
291
+ """Generate a preview of the diff application.
292
+
293
+ Args:
294
+ diff: Parsed diff to preview
295
+ repo_path: Base path of the repository
296
+
297
+ Returns:
298
+ Formatted string showing the diff
299
+ """
300
+ file_path = self._resolve_file_path(diff.original_file, repo_path)
301
+ if not file_path or not file_path.exists():
302
+ return f"[Cannot preview: file not found: {diff.original_file}]"
303
+
304
+ lines = [
305
+ f"File: {file_path}",
306
+ "-" * 60,
307
+ diff.to_string(),
308
+ "-" * 60,
309
+ ]
310
+ return "\n".join(lines)
311
+
312
+ def _resolve_file_path(self, diff_path: str, repo_path: Path) -> Path | None:
313
+ """Resolve diff file path to actual file path.
314
+
315
+ Handles common diff path prefixes like a/, b/.
316
+ """
317
+ # Remove common prefixes
318
+ clean_path = diff_path
319
+ for prefix in ("a/", "b/", "./"):
320
+ if clean_path.startswith(prefix):
321
+ clean_path = clean_path[len(prefix) :]
322
+ break
323
+
324
+ # Try as absolute path first
325
+ if os.path.isabs(clean_path):
326
+ return Path(clean_path)
327
+
328
+ # Resolve relative to repo
329
+ full_path = (repo_path / clean_path).resolve()
330
+
331
+ # Security: ensure path is within repo
332
+ try:
333
+ full_path.relative_to(repo_path.resolve())
334
+ except ValueError:
335
+ return None
336
+
337
+ return full_path
338
+
339
+ def _validate_hunk(
340
+ self, hunk: DiffHunk, file_lines: list[str], hunk_num: int
341
+ ) -> tuple[bool, str]:
342
+ """Validate that a hunk can be applied to the file."""
343
+ if hunk.old_start < 1:
344
+ return False, f"Hunk {hunk_num}: invalid start line {hunk.old_start}"
345
+
346
+ if hunk.old_start > len(file_lines) + 1:
347
+ return False, f"Hunk {hunk_num}: start line {hunk.old_start} beyond file end"
348
+
349
+ return True, ""
350
+
351
+ def _apply_hunks(self, content: str, hunks: list[DiffHunk]) -> tuple[str, dict[str, int]]:
352
+ """Apply hunks to content and return new content with stats."""
353
+ lines = content.splitlines(keepends=True)
354
+ # Ensure last line has newline for consistent processing
355
+ if lines and not lines[-1].endswith("\n"):
356
+ lines[-1] += "\n"
357
+
358
+ stats = {"added": 0, "removed": 0}
359
+ offset = 0
360
+
361
+ for hunk in sorted(hunks, key=lambda h: h.old_start):
362
+ # Adjust for previous changes
363
+ start_idx = hunk.old_start - 1 + offset
364
+
365
+ # Count lines to remove and add
366
+ remove_count = 0
367
+ add_lines: list[str] = []
368
+
369
+ for line in hunk.lines:
370
+ if line.startswith("-"):
371
+ remove_count += 1
372
+ stats["removed"] += 1
373
+ elif line.startswith("+"):
374
+ add_lines.append(line[1:] + "\n")
375
+ stats["added"] += 1
376
+ elif line.startswith(" "):
377
+ add_lines.append(line[1:] + "\n")
378
+
379
+ # Apply the change
380
+ lines[start_idx : start_idx + remove_count] = add_lines
381
+ offset += len(add_lines) - remove_count
382
+
383
+ return "".join(lines), stats
384
+
385
+ def _create_backup(self, file_path: Path) -> Path | None:
386
+ """Create a backup of the file."""
387
+ backup_dir = self._backup_dir
388
+ if not backup_dir:
389
+ backup_dir = Path(tempfile.gettempdir()) / "kekkai-fix-backups"
390
+
391
+ backup_dir.mkdir(parents=True, exist_ok=True)
392
+
393
+ timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
394
+ backup_name = f"{file_path.name}.{timestamp}.bak"
395
+ backup_path = backup_dir / backup_name
396
+
397
+ try:
398
+ shutil.copy2(file_path, backup_path)
399
+ return backup_path
400
+ except OSError:
401
+ return None
402
+
403
+
404
+ def generate_diff(original: str, modified: str, file_path: str) -> str:
405
+ """Generate a unified diff between two strings.
406
+
407
+ Args:
408
+ original: Original content
409
+ modified: Modified content
410
+ file_path: File path for diff header
411
+
412
+ Returns:
413
+ Unified diff string
414
+ """
415
+ import difflib
416
+
417
+ original_lines = original.splitlines(keepends=True)
418
+ modified_lines = modified.splitlines(keepends=True)
419
+
420
+ diff = difflib.unified_diff(
421
+ original_lines,
422
+ modified_lines,
423
+ fromfile=f"a/{file_path}",
424
+ tofile=f"b/{file_path}",
425
+ )
426
+
427
+ return "".join(diff)