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.
- kekkai/cli.py +789 -19
- kekkai/compliance/__init__.py +68 -0
- kekkai/compliance/hipaa.py +235 -0
- kekkai/compliance/mappings.py +136 -0
- kekkai/compliance/owasp.py +517 -0
- kekkai/compliance/owasp_agentic.py +267 -0
- kekkai/compliance/pci_dss.py +205 -0
- kekkai/compliance/soc2.py +209 -0
- kekkai/dojo.py +91 -14
- kekkai/dojo_import.py +9 -1
- kekkai/fix/__init__.py +47 -0
- kekkai/fix/audit.py +278 -0
- kekkai/fix/differ.py +427 -0
- kekkai/fix/engine.py +500 -0
- kekkai/fix/prompts.py +251 -0
- kekkai/output.py +10 -12
- kekkai/report/__init__.py +41 -0
- kekkai/report/compliance_matrix.py +98 -0
- kekkai/report/generator.py +365 -0
- kekkai/report/html.py +69 -0
- kekkai/report/pdf.py +63 -0
- kekkai/report/unified.py +226 -0
- kekkai/scanners/container.py +33 -3
- kekkai/scanners/gitleaks.py +3 -1
- kekkai/scanners/semgrep.py +1 -1
- kekkai/scanners/trivy.py +1 -1
- kekkai/threatflow/model_adapter.py +143 -1
- kekkai/triage/__init__.py +54 -1
- kekkai/triage/loader.py +196 -0
- kekkai_cli-1.1.1.dist-info/METADATA +379 -0
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.1.dist-info}/RECORD +34 -33
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.1.dist-info}/entry_points.txt +0 -1
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.1.dist-info}/top_level.txt +0 -1
- kekkai_cli-1.0.5.dist-info/METADATA +0 -135
- portal/__init__.py +0 -19
- portal/api.py +0 -155
- portal/auth.py +0 -103
- portal/enterprise/__init__.py +0 -32
- portal/enterprise/audit.py +0 -435
- portal/enterprise/licensing.py +0 -342
- 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 -384
- {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)
|