kekkai-cli 2.0.1__py3-none-any.whl → 2.2.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 +49 -1
- kekkai/output.py +1 -1
- kekkai/triage/__init__.py +6 -0
- kekkai/triage/app.py +12 -0
- kekkai/triage/code_context.py +369 -0
- kekkai/triage/editor_support.py +166 -0
- kekkai/triage/fix_screen.py +34 -4
- kekkai/triage/screens.py +240 -0
- {kekkai_cli-2.0.1.dist-info → kekkai_cli-2.2.1.dist-info}/METADATA +19 -30
- {kekkai_cli-2.0.1.dist-info → kekkai_cli-2.2.1.dist-info}/RECORD +13 -11
- {kekkai_cli-2.0.1.dist-info → kekkai_cli-2.2.1.dist-info}/WHEEL +0 -0
- {kekkai_cli-2.0.1.dist-info → kekkai_cli-2.2.1.dist-info}/entry_points.txt +0 -0
- {kekkai_cli-2.0.1.dist-info → kekkai_cli-2.2.1.dist-info}/top_level.txt +0 -0
kekkai/cli.py
CHANGED
|
@@ -221,6 +221,17 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
221
221
|
type=str,
|
|
222
222
|
help="Path for .kekkaiignore output (default: .kekkaiignore)",
|
|
223
223
|
)
|
|
224
|
+
triage_parser.add_argument(
|
|
225
|
+
"--repo",
|
|
226
|
+
type=str,
|
|
227
|
+
help="Repository root path (default: auto-detect from run.json)",
|
|
228
|
+
)
|
|
229
|
+
triage_parser.add_argument(
|
|
230
|
+
"--context-lines",
|
|
231
|
+
type=int,
|
|
232
|
+
default=10,
|
|
233
|
+
help="Context lines before/after line (default: 10, range: 5-100)",
|
|
234
|
+
)
|
|
224
235
|
|
|
225
236
|
# Fix subcommand - AI-powered remediation
|
|
226
237
|
fix_parser = subparsers.add_parser("fix", help="generate AI-powered code fixes for findings")
|
|
@@ -1186,6 +1197,11 @@ def _command_triage(parsed: argparse.Namespace) -> int:
|
|
|
1186
1197
|
|
|
1187
1198
|
input_path_str = cast(str | None, getattr(parsed, "input", None))
|
|
1188
1199
|
output_path_str = cast(str | None, getattr(parsed, "output", None))
|
|
1200
|
+
repo_path_str = cast(str | None, getattr(parsed, "repo", None))
|
|
1201
|
+
context_lines = cast(int, getattr(parsed, "context_lines", 10))
|
|
1202
|
+
|
|
1203
|
+
# Validate context_lines range
|
|
1204
|
+
context_lines = max(5, min(100, context_lines))
|
|
1189
1205
|
|
|
1190
1206
|
# Default to latest run if no input specified
|
|
1191
1207
|
if not input_path_str:
|
|
@@ -1232,7 +1248,39 @@ def _command_triage(parsed: argparse.Namespace) -> int:
|
|
|
1232
1248
|
|
|
1233
1249
|
console.print(f"[info]Loaded {len(findings)} finding(s)[/info]\n")
|
|
1234
1250
|
|
|
1235
|
-
|
|
1251
|
+
# Auto-detect repo_path from run.json if not explicitly provided
|
|
1252
|
+
repo_path: Path | None = None
|
|
1253
|
+
if repo_path_str:
|
|
1254
|
+
repo_path = Path(repo_path_str).expanduser().resolve()
|
|
1255
|
+
elif input_path.is_dir():
|
|
1256
|
+
# Try to read repo_path from run.json
|
|
1257
|
+
manifest_path = input_path / "run.json"
|
|
1258
|
+
if manifest_path.exists():
|
|
1259
|
+
try:
|
|
1260
|
+
import json
|
|
1261
|
+
|
|
1262
|
+
with manifest_path.open() as f:
|
|
1263
|
+
manifest_data = json.load(f)
|
|
1264
|
+
stored_repo = manifest_data.get("repo_path")
|
|
1265
|
+
if stored_repo:
|
|
1266
|
+
repo_path = Path(stored_repo).expanduser().resolve()
|
|
1267
|
+
console.print(f"[dim]Using repo path from run metadata: {repo_path}[/dim]\n")
|
|
1268
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
1269
|
+
pass
|
|
1270
|
+
|
|
1271
|
+
# Fall back to current directory if still not set (with warning)
|
|
1272
|
+
if repo_path is None:
|
|
1273
|
+
repo_path = Path.cwd()
|
|
1274
|
+
console.print("[warning]⚠ Repo path not detected. Using current directory.[/warning]")
|
|
1275
|
+
console.print("[dim]Tip: Use --repo to specify repository root explicitly.[/dim]")
|
|
1276
|
+
console.print(f"[dim]Current directory: {repo_path}[/dim]\n")
|
|
1277
|
+
|
|
1278
|
+
return run_triage(
|
|
1279
|
+
findings=findings,
|
|
1280
|
+
output_path=output_path,
|
|
1281
|
+
repo_path=repo_path,
|
|
1282
|
+
context_lines=context_lines,
|
|
1283
|
+
)
|
|
1236
1284
|
|
|
1237
1285
|
|
|
1238
1286
|
def _command_fix(parsed: argparse.Namespace) -> int:
|
kekkai/output.py
CHANGED
kekkai/triage/__init__.py
CHANGED
|
@@ -29,6 +29,8 @@ def run_triage(
|
|
|
29
29
|
input_path: Path | None = None,
|
|
30
30
|
output_path: Path | None = None,
|
|
31
31
|
findings: Sequence[FindingEntry] | None = None,
|
|
32
|
+
repo_path: Path | None = None,
|
|
33
|
+
context_lines: int = 10,
|
|
32
34
|
) -> int:
|
|
33
35
|
"""Run the triage TUI (lazy import).
|
|
34
36
|
|
|
@@ -36,6 +38,8 @@ def run_triage(
|
|
|
36
38
|
input_path: Path to findings JSON file.
|
|
37
39
|
output_path: Path for .kekkaiignore output.
|
|
38
40
|
findings: Pre-loaded findings (alternative to input_path).
|
|
41
|
+
repo_path: Repository root path for code context display.
|
|
42
|
+
context_lines: Number of lines to show before/after vulnerable line.
|
|
39
43
|
|
|
40
44
|
Returns:
|
|
41
45
|
Exit code (0 for success).
|
|
@@ -50,6 +54,8 @@ def run_triage(
|
|
|
50
54
|
input_path=input_path,
|
|
51
55
|
output_path=output_path,
|
|
52
56
|
findings=findings,
|
|
57
|
+
repo_path=repo_path,
|
|
58
|
+
context_lines=context_lines,
|
|
53
59
|
)
|
|
54
60
|
except ImportError as e:
|
|
55
61
|
raise RuntimeError(
|
kekkai/triage/app.py
CHANGED
|
@@ -51,6 +51,8 @@ class TriageApp(App[None]):
|
|
|
51
51
|
input_path: Path | None = None,
|
|
52
52
|
output_path: Path | None = None,
|
|
53
53
|
audit_path: Path | None = None,
|
|
54
|
+
repo_path: Path | None = None,
|
|
55
|
+
context_lines: int = 10,
|
|
54
56
|
) -> None:
|
|
55
57
|
"""Initialize triage application.
|
|
56
58
|
|
|
@@ -59,6 +61,8 @@ class TriageApp(App[None]):
|
|
|
59
61
|
input_path: Path to findings JSON file.
|
|
60
62
|
output_path: Path for .kekkaiignore output.
|
|
61
63
|
audit_path: Path for audit log.
|
|
64
|
+
repo_path: Repository root path for code context display.
|
|
65
|
+
context_lines: Number of lines to show before/after vulnerable line.
|
|
62
66
|
"""
|
|
63
67
|
super().__init__()
|
|
64
68
|
self._input_path = input_path
|
|
@@ -66,6 +70,8 @@ class TriageApp(App[None]):
|
|
|
66
70
|
self.ignore_file = IgnoreFile(output_path)
|
|
67
71
|
self.audit_log = TriageAuditLog(audit_path)
|
|
68
72
|
self._decisions: dict[str, TriageDecision] = {}
|
|
73
|
+
self.repo_path = repo_path or Path.cwd()
|
|
74
|
+
self.context_lines = context_lines
|
|
69
75
|
|
|
70
76
|
@property
|
|
71
77
|
def findings(self) -> list[FindingEntry]:
|
|
@@ -148,6 +154,8 @@ def run_triage(
|
|
|
148
154
|
input_path: Path | None = None,
|
|
149
155
|
output_path: Path | None = None,
|
|
150
156
|
findings: Sequence[FindingEntry] | None = None,
|
|
157
|
+
repo_path: Path | None = None,
|
|
158
|
+
context_lines: int = 10,
|
|
151
159
|
) -> int:
|
|
152
160
|
"""Run the triage TUI.
|
|
153
161
|
|
|
@@ -155,6 +163,8 @@ def run_triage(
|
|
|
155
163
|
input_path: Path to findings JSON file.
|
|
156
164
|
output_path: Path for .kekkaiignore output.
|
|
157
165
|
findings: Pre-loaded findings (alternative to input_path).
|
|
166
|
+
repo_path: Repository root path for code context display.
|
|
167
|
+
context_lines: Number of lines to show before/after vulnerable line.
|
|
158
168
|
|
|
159
169
|
Returns:
|
|
160
170
|
Exit code (0 for success).
|
|
@@ -163,6 +173,8 @@ def run_triage(
|
|
|
163
173
|
findings=findings,
|
|
164
174
|
input_path=input_path,
|
|
165
175
|
output_path=output_path,
|
|
176
|
+
repo_path=repo_path,
|
|
177
|
+
context_lines=context_lines,
|
|
166
178
|
)
|
|
167
179
|
app.run()
|
|
168
180
|
return 0
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Code context extraction for triage TUI.
|
|
2
|
+
|
|
3
|
+
Provides secure code context extraction with security controls:
|
|
4
|
+
- Path traversal protection (ASVS V5.3.3)
|
|
5
|
+
- File size limits (ASVS V10.3.3)
|
|
6
|
+
- Sensitive file detection (ASVS V8.3.4)
|
|
7
|
+
- Error sanitization (ASVS V7.4.1)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..fix.prompts import FixPromptBuilder
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CodeContext",
|
|
22
|
+
"CodeContextExtractor",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Security limits per ASVS V10.3.3
|
|
26
|
+
MAX_FILE_SIZE_MB = 10
|
|
27
|
+
|
|
28
|
+
# Sensitive file extensions to block (ASVS V8.3.4)
|
|
29
|
+
SENSITIVE_EXTENSIONS = {
|
|
30
|
+
".env",
|
|
31
|
+
".pem",
|
|
32
|
+
".key",
|
|
33
|
+
".crt",
|
|
34
|
+
".p12",
|
|
35
|
+
".pfx",
|
|
36
|
+
".jks",
|
|
37
|
+
".keystore",
|
|
38
|
+
".pub",
|
|
39
|
+
"id_rsa",
|
|
40
|
+
"id_dsa",
|
|
41
|
+
"id_ecdsa",
|
|
42
|
+
"id_ed25519",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Binary file extensions to skip
|
|
46
|
+
BINARY_EXTENSIONS = {
|
|
47
|
+
".pyc",
|
|
48
|
+
".pyo",
|
|
49
|
+
".so",
|
|
50
|
+
".dll",
|
|
51
|
+
".dylib",
|
|
52
|
+
".exe",
|
|
53
|
+
".bin",
|
|
54
|
+
".class",
|
|
55
|
+
".jar",
|
|
56
|
+
".war",
|
|
57
|
+
".ear",
|
|
58
|
+
".png",
|
|
59
|
+
".jpg",
|
|
60
|
+
".jpeg",
|
|
61
|
+
".gif",
|
|
62
|
+
".bmp",
|
|
63
|
+
".ico",
|
|
64
|
+
".pdf",
|
|
65
|
+
".zip",
|
|
66
|
+
".tar",
|
|
67
|
+
".gz",
|
|
68
|
+
".bz2",
|
|
69
|
+
".7z",
|
|
70
|
+
".rar",
|
|
71
|
+
".whl",
|
|
72
|
+
".egg",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CodeContext:
|
|
78
|
+
"""Code context with syntax highlighting metadata.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
code: Formatted code with line numbers and >>> marker.
|
|
82
|
+
language: Detected programming language for syntax highlighting.
|
|
83
|
+
vulnerable_line: The specific vulnerable line text.
|
|
84
|
+
error: Error message if extraction failed (None on success).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
code: str
|
|
88
|
+
language: str
|
|
89
|
+
vulnerable_line: str
|
|
90
|
+
error: str | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CodeContextExtractor:
|
|
94
|
+
"""Extracts code context from files with security controls.
|
|
95
|
+
|
|
96
|
+
Security features:
|
|
97
|
+
- Path validation to prevent traversal attacks (ASVS V5.3.3)
|
|
98
|
+
- File size limits to prevent DoS (ASVS V10.3.3)
|
|
99
|
+
- Sensitive file detection (ASVS V8.3.4)
|
|
100
|
+
- Sanitized error messages (ASVS V7.4.1)
|
|
101
|
+
|
|
102
|
+
Performance features:
|
|
103
|
+
- LRU cache for file contents (max 20 files, ~200KB per file = ~4MB total)
|
|
104
|
+
- Reduces re-reads when navigating between findings in same files
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, repo_path: Path, max_file_size_mb: int = MAX_FILE_SIZE_MB) -> None:
|
|
108
|
+
"""Initialize code context extractor.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
repo_path: Repository root path (for path validation).
|
|
112
|
+
max_file_size_mb: Maximum file size in MB (DoS protection).
|
|
113
|
+
"""
|
|
114
|
+
self.repo_path = repo_path.resolve()
|
|
115
|
+
self.max_file_size_mb = max_file_size_mb
|
|
116
|
+
self._prompt_builder = FixPromptBuilder(context_lines=10)
|
|
117
|
+
# Simple LRU cache: {file_path: file_content}
|
|
118
|
+
# Limited to 20 files to prevent memory bloat
|
|
119
|
+
self._file_cache: dict[str, str] = {}
|
|
120
|
+
self._cache_max_size = 20
|
|
121
|
+
|
|
122
|
+
def extract(self, file_path: str, line: int | None) -> CodeContext | None:
|
|
123
|
+
"""Extract code context from a file.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
file_path: Relative or absolute path to the file.
|
|
127
|
+
line: Line number (1-indexed) of the vulnerability.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
CodeContext object with code and metadata, or None if unavailable.
|
|
131
|
+
|
|
132
|
+
Security:
|
|
133
|
+
- Path validation (ASVS V5.3.3)
|
|
134
|
+
- Size limits (ASVS V10.3.3)
|
|
135
|
+
- Sensitive file blocking (ASVS V8.3.4)
|
|
136
|
+
- Error sanitization (ASVS V7.4.1)
|
|
137
|
+
"""
|
|
138
|
+
if not file_path or not line:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
# Resolve path
|
|
142
|
+
try:
|
|
143
|
+
full_path = (self.repo_path / file_path).resolve()
|
|
144
|
+
except (ValueError, OSError):
|
|
145
|
+
# ASVS V7.4.1: Sanitized error (no full path)
|
|
146
|
+
logger.warning(
|
|
147
|
+
"code_context_path_invalid",
|
|
148
|
+
extra={"file_path": Path(file_path).name, "reason": "invalid_path"},
|
|
149
|
+
)
|
|
150
|
+
return CodeContext(
|
|
151
|
+
code="",
|
|
152
|
+
language="",
|
|
153
|
+
vulnerable_line="",
|
|
154
|
+
error="Invalid file path",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# ASVS V5.3.3: Path traversal check
|
|
158
|
+
if not self._validate_path(full_path):
|
|
159
|
+
logger.warning(
|
|
160
|
+
"code_context_path_traversal",
|
|
161
|
+
extra={"file_path": Path(file_path).name, "reason": "path_traversal"},
|
|
162
|
+
)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
# Check if file exists
|
|
166
|
+
if not full_path.exists():
|
|
167
|
+
logger.info(
|
|
168
|
+
"code_context_file_not_found",
|
|
169
|
+
extra={"file_path": Path(file_path).name},
|
|
170
|
+
)
|
|
171
|
+
return CodeContext(
|
|
172
|
+
code="",
|
|
173
|
+
language="",
|
|
174
|
+
vulnerable_line="",
|
|
175
|
+
error="File not found",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ASVS V8.3.4: Sensitive file check
|
|
179
|
+
if self._is_sensitive_file(full_path):
|
|
180
|
+
logger.info(
|
|
181
|
+
"code_context_sensitive_file",
|
|
182
|
+
extra={"file_path": Path(file_path).name},
|
|
183
|
+
)
|
|
184
|
+
return CodeContext(
|
|
185
|
+
code="",
|
|
186
|
+
language="",
|
|
187
|
+
vulnerable_line="",
|
|
188
|
+
error="Code hidden (sensitive file type)",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Binary file check
|
|
192
|
+
if not self._is_text_file(full_path):
|
|
193
|
+
logger.info(
|
|
194
|
+
"code_context_binary_file",
|
|
195
|
+
extra={"file_path": Path(file_path).name},
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
# ASVS V10.3.3: File size check
|
|
200
|
+
size_mb = full_path.stat().st_size / (1024 * 1024)
|
|
201
|
+
if size_mb > self.max_file_size_mb:
|
|
202
|
+
logger.warning(
|
|
203
|
+
"code_context_file_too_large",
|
|
204
|
+
extra={"file_path": Path(file_path).name, "size_mb": f"{size_mb:.1f}"},
|
|
205
|
+
)
|
|
206
|
+
return CodeContext(
|
|
207
|
+
code="",
|
|
208
|
+
language="",
|
|
209
|
+
vulnerable_line="",
|
|
210
|
+
error=f"File too large for display ({size_mb:.1f}MB)",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Read file content (with caching for performance and encoding fallback)
|
|
214
|
+
cache_key = str(full_path)
|
|
215
|
+
if cache_key in self._file_cache:
|
|
216
|
+
file_content = self._file_cache[cache_key]
|
|
217
|
+
else:
|
|
218
|
+
try:
|
|
219
|
+
# Try UTF-8 first (strict mode)
|
|
220
|
+
file_content = full_path.read_text(encoding="utf-8")
|
|
221
|
+
except UnicodeDecodeError:
|
|
222
|
+
# HOTFIX: Fallback to UTF-8 with replacement characters
|
|
223
|
+
# This allows viewing legacy files (Windows-1252, Latin-1) with � for invalid chars
|
|
224
|
+
# instead of completely hiding the file with "Cannot read file" error
|
|
225
|
+
logger.info(
|
|
226
|
+
"code_context_encoding_fallback",
|
|
227
|
+
extra={"file_path": Path(file_path).name, "reason": "non_utf8"},
|
|
228
|
+
)
|
|
229
|
+
try:
|
|
230
|
+
file_content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
231
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
232
|
+
# Even fallback failed (should be rare - permissions, etc.)
|
|
233
|
+
logger.warning(
|
|
234
|
+
"code_context_read_error",
|
|
235
|
+
extra={"file_path": Path(file_path).name, "error": str(e)},
|
|
236
|
+
)
|
|
237
|
+
return CodeContext(
|
|
238
|
+
code="",
|
|
239
|
+
language="",
|
|
240
|
+
vulnerable_line="",
|
|
241
|
+
error="Cannot read file (encoding error)",
|
|
242
|
+
)
|
|
243
|
+
except OSError as e:
|
|
244
|
+
# File read error (permissions, etc.)
|
|
245
|
+
logger.warning(
|
|
246
|
+
"code_context_read_error",
|
|
247
|
+
extra={"file_path": Path(file_path).name, "error": str(e)},
|
|
248
|
+
)
|
|
249
|
+
return CodeContext(
|
|
250
|
+
code="",
|
|
251
|
+
language="",
|
|
252
|
+
vulnerable_line="",
|
|
253
|
+
error="Cannot read file",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Cache the content
|
|
257
|
+
self._file_cache[cache_key] = file_content
|
|
258
|
+
# Evict oldest entry if cache is full (simple FIFO)
|
|
259
|
+
if len(self._file_cache) > self._cache_max_size:
|
|
260
|
+
# Remove first (oldest) entry
|
|
261
|
+
oldest_key = next(iter(self._file_cache))
|
|
262
|
+
del self._file_cache[oldest_key]
|
|
263
|
+
|
|
264
|
+
# Extract code context using existing logic from fix engine
|
|
265
|
+
code_context, vulnerable_line = self._prompt_builder.extract_code_context(
|
|
266
|
+
file_content, line
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Detect language for syntax highlighting
|
|
270
|
+
language = self._detect_language(full_path)
|
|
271
|
+
|
|
272
|
+
return CodeContext(
|
|
273
|
+
code=code_context,
|
|
274
|
+
language=language,
|
|
275
|
+
vulnerable_line=vulnerable_line,
|
|
276
|
+
error=None,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _validate_path(self, path: Path) -> bool:
|
|
280
|
+
"""Validate that path is within repo_path (prevent traversal).
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
path: Resolved path to validate.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
True if path is safe, False otherwise.
|
|
287
|
+
|
|
288
|
+
Security:
|
|
289
|
+
ASVS V5.3.3: Path validation to prevent directory traversal.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
# Check if path is within repo_path
|
|
293
|
+
path.relative_to(self.repo_path)
|
|
294
|
+
return True
|
|
295
|
+
except ValueError:
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
def _is_text_file(self, path: Path) -> bool:
|
|
299
|
+
"""Check if file is a text file (not binary).
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
path: Path to check.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True if likely a text file, False if binary.
|
|
306
|
+
"""
|
|
307
|
+
suffix = path.suffix.lower()
|
|
308
|
+
name = path.name.lower()
|
|
309
|
+
|
|
310
|
+
# Check against binary extensions
|
|
311
|
+
if suffix in BINARY_EXTENSIONS:
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
# Special cases without extensions
|
|
315
|
+
if name in ("dockerfile", "makefile", "vagrantfile", "jenkinsfile"):
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
def _is_sensitive_file(self, path: Path) -> bool:
|
|
321
|
+
"""Check if file contains sensitive data (secrets, keys).
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
path: Path to check.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True if file is sensitive and should not be displayed.
|
|
328
|
+
|
|
329
|
+
Security:
|
|
330
|
+
ASVS V8.3.4: Prevent sensitive data in outputs.
|
|
331
|
+
"""
|
|
332
|
+
suffix = path.suffix.lower()
|
|
333
|
+
name = path.name.lower()
|
|
334
|
+
|
|
335
|
+
# Check extension
|
|
336
|
+
if suffix in SENSITIVE_EXTENSIONS:
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
# Check if the entire filename (including leading dot) matches sensitive patterns
|
|
340
|
+
# For files like .env, .pem, etc., the suffix is empty but name includes the dot
|
|
341
|
+
if name in {".env", ".pem", ".key", ".crt"}:
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
# Check filename patterns
|
|
345
|
+
return any(
|
|
346
|
+
pattern in name
|
|
347
|
+
for pattern in [
|
|
348
|
+
"secret",
|
|
349
|
+
"credential",
|
|
350
|
+
"password",
|
|
351
|
+
"token",
|
|
352
|
+
"apikey",
|
|
353
|
+
"private_key",
|
|
354
|
+
"id_rsa",
|
|
355
|
+
"id_dsa",
|
|
356
|
+
"id_ecdsa",
|
|
357
|
+
]
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _detect_language(self, path: Path) -> str:
|
|
361
|
+
"""Detect programming language from file extension.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
path: Path to the file.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Language identifier for syntax highlighting.
|
|
368
|
+
"""
|
|
369
|
+
return self._prompt_builder._detect_language(str(path))
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Editor-specific line jump syntax support.
|
|
2
|
+
|
|
3
|
+
Provides detection and command building for popular editors with
|
|
4
|
+
security validation per ASVS V5.1.3.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"EditorConfig",
|
|
15
|
+
"detect_editor_config",
|
|
16
|
+
"validate_editor_name",
|
|
17
|
+
"EDITOR_REGISTRY",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# ASVS V5.1.3: Only allow safe characters in editor names
|
|
21
|
+
EDITOR_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9/_.-]+$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class EditorConfig:
|
|
26
|
+
"""Editor-specific command line configuration.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
name: Canonical editor name (e.g., "vim", "code", "subl").
|
|
30
|
+
syntax_type: Command syntax category for building commands.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
syntax_type: str # "vim", "vscode", "sublime", "notepadpp", "jetbrains"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Editor registry mapping base names to configurations
|
|
38
|
+
EDITOR_REGISTRY: dict[str, EditorConfig] = {
|
|
39
|
+
# Vim family (syntax: editor +LINE file)
|
|
40
|
+
"vim": EditorConfig("vim", "vim"),
|
|
41
|
+
"nvim": EditorConfig("nvim", "vim"),
|
|
42
|
+
"neovim": EditorConfig("neovim", "vim"),
|
|
43
|
+
"vi": EditorConfig("vi", "vim"),
|
|
44
|
+
# Emacs family (syntax: editor +LINE file)
|
|
45
|
+
"emacs": EditorConfig("emacs", "vim"),
|
|
46
|
+
"nano": EditorConfig("nano", "vim"),
|
|
47
|
+
# VS Code (syntax: code -g file:line)
|
|
48
|
+
"code": EditorConfig("code", "vscode"),
|
|
49
|
+
"code-insiders": EditorConfig("code-insiders", "vscode"),
|
|
50
|
+
"codium": EditorConfig("codium", "vscode"), # VSCodium (FOSS fork)
|
|
51
|
+
# Sublime Text (syntax: subl file:line)
|
|
52
|
+
"subl": EditorConfig("subl", "sublime"),
|
|
53
|
+
"sublime": EditorConfig("sublime", "sublime"),
|
|
54
|
+
"sublime_text": EditorConfig("sublime_text", "sublime"),
|
|
55
|
+
# Atom (syntax: atom file:line) - legacy but still used
|
|
56
|
+
"atom": EditorConfig("atom", "sublime"),
|
|
57
|
+
# Notepad++ (syntax: notepad++ -nLINE file)
|
|
58
|
+
"notepad++": EditorConfig("notepad++", "notepadpp"),
|
|
59
|
+
"notepad++.exe": EditorConfig("notepad++.exe", "notepadpp"),
|
|
60
|
+
# JetBrains IDEs (syntax: editor --line LINE file)
|
|
61
|
+
"idea": EditorConfig("idea", "jetbrains"),
|
|
62
|
+
"pycharm": EditorConfig("pycharm", "jetbrains"),
|
|
63
|
+
"webstorm": EditorConfig("webstorm", "jetbrains"),
|
|
64
|
+
"phpstorm": EditorConfig("phpstorm", "jetbrains"),
|
|
65
|
+
"goland": EditorConfig("goland", "jetbrains"),
|
|
66
|
+
"rider": EditorConfig("rider", "jetbrains"),
|
|
67
|
+
"clion": EditorConfig("clion", "jetbrains"),
|
|
68
|
+
"rubymine": EditorConfig("rubymine", "jetbrains"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_editor_config(editor_name: str) -> EditorConfig:
|
|
73
|
+
"""Detect editor configuration from name.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
editor_name: Editor executable name (e.g., "vim", "/usr/bin/code").
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
EditorConfig for the detected editor, or vim-style default if unknown.
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
>>> detect_editor_config("vim").syntax_type
|
|
83
|
+
'vim'
|
|
84
|
+
>>> detect_editor_config("/usr/local/bin/code").syntax_type
|
|
85
|
+
'vscode'
|
|
86
|
+
>>> detect_editor_config("unknown-editor").syntax_type
|
|
87
|
+
'vim'
|
|
88
|
+
"""
|
|
89
|
+
# Extract base name from path
|
|
90
|
+
base_name = Path(editor_name).stem.lower()
|
|
91
|
+
|
|
92
|
+
# Handle .exe extension on Windows
|
|
93
|
+
if base_name.endswith(".exe"):
|
|
94
|
+
base_name = base_name[:-4]
|
|
95
|
+
|
|
96
|
+
# Lookup in registry
|
|
97
|
+
config = EDITOR_REGISTRY.get(base_name)
|
|
98
|
+
if config:
|
|
99
|
+
return config
|
|
100
|
+
|
|
101
|
+
# Default to vim-style syntax for unknown editors
|
|
102
|
+
return EditorConfig("unknown", "vim")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_editor_name(editor: str) -> bool:
|
|
106
|
+
"""Validate that editor name is safe to use.
|
|
107
|
+
|
|
108
|
+
Security: ASVS V5.1.3 - Validate data at trust boundaries.
|
|
109
|
+
Rejects editor names containing shell metacharacters to prevent
|
|
110
|
+
command injection attacks.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
editor: Editor name from environment variable.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if editor name is safe, False if it contains unsafe characters.
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
>>> validate_editor_name("vim")
|
|
120
|
+
True
|
|
121
|
+
>>> validate_editor_name("/usr/bin/code")
|
|
122
|
+
True
|
|
123
|
+
>>> validate_editor_name("vim; curl evil.com")
|
|
124
|
+
False
|
|
125
|
+
>>> validate_editor_name("vim && rm -rf /")
|
|
126
|
+
False
|
|
127
|
+
"""
|
|
128
|
+
if not editor:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# Check against safe pattern (alphanumeric + /.-_ only)
|
|
132
|
+
return bool(EDITOR_NAME_PATTERN.match(editor))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_editor_command(
|
|
136
|
+
editor_path: str, file_path: Path, line: int, editor_config: EditorConfig
|
|
137
|
+
) -> list[str]:
|
|
138
|
+
"""Build editor command arguments based on editor type.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
editor_path: Full path to editor executable.
|
|
142
|
+
file_path: Path to file to open.
|
|
143
|
+
line: Line number to jump to.
|
|
144
|
+
editor_config: Editor configuration with syntax type.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of command arguments for subprocess.run().
|
|
148
|
+
|
|
149
|
+
Security:
|
|
150
|
+
ASVS V14.2.1 - Uses list args (not shell string) to prevent injection.
|
|
151
|
+
"""
|
|
152
|
+
if editor_config.syntax_type == "vscode":
|
|
153
|
+
# VS Code: code -g file:line
|
|
154
|
+
return [editor_path, "-g", f"{file_path}:{line}"]
|
|
155
|
+
elif editor_config.syntax_type == "sublime":
|
|
156
|
+
# Sublime Text / Atom: editor file:line
|
|
157
|
+
return [editor_path, f"{file_path}:{line}"]
|
|
158
|
+
elif editor_config.syntax_type == "notepadpp":
|
|
159
|
+
# Notepad++: notepad++ -nLINE file
|
|
160
|
+
return [editor_path, f"-n{line}", str(file_path)]
|
|
161
|
+
elif editor_config.syntax_type == "jetbrains":
|
|
162
|
+
# JetBrains IDEs: editor --line LINE file
|
|
163
|
+
return [editor_path, "--line", str(line), str(file_path)]
|
|
164
|
+
else:
|
|
165
|
+
# Default (Vim/Emacs/Nano): editor +LINE file
|
|
166
|
+
return [editor_path, f"+{line}", str(file_path)]
|
kekkai/triage/fix_screen.py
CHANGED
|
@@ -213,17 +213,47 @@ class FixGenerationScreen(ModalScreen[bool]):
|
|
|
213
213
|
self.fix_generated = False
|
|
214
214
|
|
|
215
215
|
def action_accept(self) -> None:
|
|
216
|
-
"""Accept and apply the generated fix."""
|
|
216
|
+
"""Accept and apply the generated fix (workbench: step 3 - verification)."""
|
|
217
217
|
if not self.fix_generated:
|
|
218
218
|
return
|
|
219
219
|
|
|
220
|
+
# Generate post-fix verification hints
|
|
221
|
+
verification_message = self._get_verification_hints()
|
|
222
|
+
|
|
220
223
|
if self.on_fix_generated:
|
|
221
|
-
self.on_fix_generated(
|
|
222
|
-
True, "Fix generated successfully (dry-run mode - review before applying)"
|
|
223
|
-
)
|
|
224
|
+
self.on_fix_generated(True, verification_message)
|
|
224
225
|
|
|
225
226
|
self.dismiss(True)
|
|
226
227
|
|
|
228
|
+
def _get_verification_hints(self) -> str:
|
|
229
|
+
"""Generate next-steps hints after fix is applied."""
|
|
230
|
+
file_path = self.finding.file_path or "file"
|
|
231
|
+
file_name = file_path.split("/")[-1] if "/" in file_path else file_path
|
|
232
|
+
|
|
233
|
+
# Detect likely test command based on file extension
|
|
234
|
+
test_cmd = "pytest tests/"
|
|
235
|
+
if file_path.endswith(".js") or file_path.endswith(".ts"):
|
|
236
|
+
test_cmd = "npm test"
|
|
237
|
+
elif file_path.endswith(".go"):
|
|
238
|
+
test_cmd = "go test ./..."
|
|
239
|
+
elif file_path.endswith(".rs"):
|
|
240
|
+
test_cmd = "cargo test"
|
|
241
|
+
elif file_path.endswith(".java"):
|
|
242
|
+
test_cmd = "mvn test"
|
|
243
|
+
|
|
244
|
+
hints = [
|
|
245
|
+
f"✓ Fix applied to {file_name}",
|
|
246
|
+
"",
|
|
247
|
+
"📋 Next steps:",
|
|
248
|
+
f" 1. Run tests: {test_cmd}",
|
|
249
|
+
" 2. Re-scan: kekkai scan --repo .",
|
|
250
|
+
f" 3. Commit: git add {file_path} && git commit -m 'fix: {self.finding.title[:50]}'",
|
|
251
|
+
"",
|
|
252
|
+
"💡 Tip: Review the changes carefully before committing!",
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
return "\n".join(hints)
|
|
256
|
+
|
|
227
257
|
def action_cancel(self) -> None:
|
|
228
258
|
"""Cancel fix generation."""
|
|
229
259
|
if self.on_fix_generated:
|
kekkai/triage/screens.py
CHANGED
|
@@ -6,8 +6,10 @@ with keyboard navigation and action handling.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
from typing import TYPE_CHECKING
|
|
10
11
|
|
|
12
|
+
from rich.syntax import Syntax
|
|
11
13
|
from rich.text import Text
|
|
12
14
|
from textual.app import ComposeResult
|
|
13
15
|
from textual.binding import Binding
|
|
@@ -15,6 +17,7 @@ from textual.containers import Vertical, VerticalScroll
|
|
|
15
17
|
from textual.screen import Screen
|
|
16
18
|
from textual.widgets import Footer, Header, Label, Static, TextArea
|
|
17
19
|
|
|
20
|
+
from .code_context import CodeContextExtractor
|
|
18
21
|
from .models import TriageState
|
|
19
22
|
from .widgets import FindingCard, sanitize_display
|
|
20
23
|
|
|
@@ -149,10 +152,15 @@ class FindingListScreen(Screen[None]):
|
|
|
149
152
|
if not self.findings:
|
|
150
153
|
return
|
|
151
154
|
finding = self.findings[self.selected_index]
|
|
155
|
+
# Get repo_path and context_lines from app if available
|
|
156
|
+
repo_path = getattr(self.app, "repo_path", None)
|
|
157
|
+
context_lines = getattr(self.app, "context_lines", 10)
|
|
152
158
|
self.app.push_screen(
|
|
153
159
|
FindingDetailScreen(
|
|
154
160
|
finding,
|
|
155
161
|
on_state_change=self._handle_detail_state_change,
|
|
162
|
+
repo_path=repo_path,
|
|
163
|
+
context_lines=context_lines,
|
|
156
164
|
)
|
|
157
165
|
)
|
|
158
166
|
|
|
@@ -212,6 +220,10 @@ class FindingDetailScreen(Screen[None]):
|
|
|
212
220
|
"""
|
|
213
221
|
|
|
214
222
|
BINDINGS = [
|
|
223
|
+
Binding("x", "fix_with_ai", "🤖 AI Fix"),
|
|
224
|
+
Binding("ctrl+o", "open_in_editor", "Open in Editor"),
|
|
225
|
+
Binding("e", "expand_context", "Expand Context"),
|
|
226
|
+
Binding("s", "shrink_context", "Shrink Context"),
|
|
215
227
|
Binding("f", "mark_false_positive", "False Positive"),
|
|
216
228
|
Binding("c", "mark_confirmed", "Confirmed"),
|
|
217
229
|
Binding("d", "mark_deferred", "Deferred"),
|
|
@@ -234,6 +246,24 @@ class FindingDetailScreen(Screen[None]):
|
|
|
234
246
|
padding: 1;
|
|
235
247
|
border: solid $primary;
|
|
236
248
|
}
|
|
249
|
+
#code-context-display {
|
|
250
|
+
height: 20;
|
|
251
|
+
border: solid $accent;
|
|
252
|
+
padding: 1;
|
|
253
|
+
margin: 1 0;
|
|
254
|
+
overflow-y: scroll;
|
|
255
|
+
}
|
|
256
|
+
.error-message {
|
|
257
|
+
color: $warning;
|
|
258
|
+
italic: true;
|
|
259
|
+
}
|
|
260
|
+
#action-hints {
|
|
261
|
+
height: auto;
|
|
262
|
+
padding: 1;
|
|
263
|
+
margin: 1 0;
|
|
264
|
+
background: $panel;
|
|
265
|
+
border: solid $secondary;
|
|
266
|
+
}
|
|
237
267
|
#notes-area {
|
|
238
268
|
height: 8;
|
|
239
269
|
margin-top: 1;
|
|
@@ -245,12 +275,17 @@ class FindingDetailScreen(Screen[None]):
|
|
|
245
275
|
self,
|
|
246
276
|
finding: FindingEntry,
|
|
247
277
|
on_state_change: Callable[[TriageState, str], None] | None = None,
|
|
278
|
+
repo_path: Path | None = None,
|
|
279
|
+
context_lines: int = 10,
|
|
248
280
|
name: str | None = None,
|
|
249
281
|
id: str | None = None,
|
|
250
282
|
) -> None:
|
|
251
283
|
super().__init__(name=name, id=id)
|
|
252
284
|
self.finding = finding
|
|
253
285
|
self.on_state_change = on_state_change
|
|
286
|
+
self.repo_path = repo_path or Path.cwd()
|
|
287
|
+
self.context_lines = context_lines
|
|
288
|
+
self._code_extractor = CodeContextExtractor(self.repo_path)
|
|
254
289
|
|
|
255
290
|
def compose(self) -> ComposeResult:
|
|
256
291
|
yield Header()
|
|
@@ -258,6 +293,13 @@ class FindingDetailScreen(Screen[None]):
|
|
|
258
293
|
yield Static(self._header_text(), id="detail-header")
|
|
259
294
|
with VerticalScroll(id="detail-content"):
|
|
260
295
|
yield Static(self._detail_text())
|
|
296
|
+
# Add code context if available
|
|
297
|
+
code_widget = self._render_code_context()
|
|
298
|
+
if code_widget:
|
|
299
|
+
yield Label("Code Context:")
|
|
300
|
+
yield code_widget
|
|
301
|
+
# Add action hints to make workflow discoverable
|
|
302
|
+
yield Static(self._action_hints(), id="action-hints")
|
|
261
303
|
yield Label("Notes (will be saved with decision):")
|
|
262
304
|
yield TextArea(self.finding.notes, id="notes-area")
|
|
263
305
|
yield Footer()
|
|
@@ -340,3 +382,201 @@ class FindingDetailScreen(Screen[None]):
|
|
|
340
382
|
def action_go_back(self) -> None:
|
|
341
383
|
"""Go back to list screen."""
|
|
342
384
|
self.app.pop_screen()
|
|
385
|
+
|
|
386
|
+
def _action_hints(self) -> Text:
|
|
387
|
+
"""Generate action hints to make workflow discoverable."""
|
|
388
|
+
text = Text()
|
|
389
|
+
text.append("💡 Actions: ", style="bold")
|
|
390
|
+
text.append("Press ", style="dim")
|
|
391
|
+
text.append("X", style="bold cyan")
|
|
392
|
+
text.append(" for AI-powered fix | ", style="dim")
|
|
393
|
+
text.append("Ctrl+O", style="bold cyan")
|
|
394
|
+
text.append(" to open in ", style="dim")
|
|
395
|
+
text.append("$EDITOR", style="italic")
|
|
396
|
+
text.append(" | ", style="dim")
|
|
397
|
+
text.append("E", style="bold cyan")
|
|
398
|
+
text.append("/", style="dim")
|
|
399
|
+
text.append("S", style="bold cyan")
|
|
400
|
+
text.append(" to expand/shrink context", style="dim")
|
|
401
|
+
return text
|
|
402
|
+
|
|
403
|
+
def action_fix_with_ai(self) -> None:
|
|
404
|
+
"""Trigger AI-powered fix generation (workbench: step 2)."""
|
|
405
|
+
# Check if file path and line exist (required for fix)
|
|
406
|
+
if not self.finding.file_path or not self.finding.line:
|
|
407
|
+
self.notify(
|
|
408
|
+
"Cannot generate fix: no file path or line number",
|
|
409
|
+
severity="warning",
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
# Import and show fix generation screen
|
|
414
|
+
try:
|
|
415
|
+
from .fix_screen import FixGenerationScreen
|
|
416
|
+
|
|
417
|
+
def on_fix_result(accepted: bool, preview: str) -> None:
|
|
418
|
+
if accepted:
|
|
419
|
+
self.notify("Fix generation completed!", severity="information")
|
|
420
|
+
else:
|
|
421
|
+
self.notify("Fix generation cancelled", severity="information")
|
|
422
|
+
|
|
423
|
+
self.app.push_screen(
|
|
424
|
+
FixGenerationScreen(
|
|
425
|
+
finding=self.finding,
|
|
426
|
+
on_fix_generated=on_fix_result,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
except ImportError:
|
|
430
|
+
self.notify("AI fix feature not available", severity="error")
|
|
431
|
+
|
|
432
|
+
def action_open_in_editor(self) -> None:
|
|
433
|
+
"""Open file in external $EDITOR at the vulnerable line (workbench: step 2)."""
|
|
434
|
+
import logging
|
|
435
|
+
import os
|
|
436
|
+
import shutil
|
|
437
|
+
import subprocess
|
|
438
|
+
|
|
439
|
+
from .editor_support import build_editor_command, detect_editor_config, validate_editor_name
|
|
440
|
+
|
|
441
|
+
logger = logging.getLogger(__name__)
|
|
442
|
+
|
|
443
|
+
if not self.finding.file_path or not self.finding.line:
|
|
444
|
+
self.notify(
|
|
445
|
+
"Cannot open editor: no file path or line number",
|
|
446
|
+
severity="warning",
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
# Get editor from environment (ASVS V5.1.3: validate before use)
|
|
451
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
452
|
+
|
|
453
|
+
# ASVS V5.1.3: Validate EDITOR value before use (reject shell metacharacters)
|
|
454
|
+
if not validate_editor_name(editor):
|
|
455
|
+
self.notify(
|
|
456
|
+
f"Editor '{editor}' contains unsafe characters. Set a valid $EDITOR.",
|
|
457
|
+
severity="error",
|
|
458
|
+
)
|
|
459
|
+
logger.warning("unsafe_editor_value", extra={"editor": editor})
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
# Security validation: check editor exists and is executable
|
|
463
|
+
editor_path = shutil.which(editor)
|
|
464
|
+
if not editor_path:
|
|
465
|
+
self.notify(
|
|
466
|
+
f"Editor '{editor}' not found. Set $EDITOR environment variable.",
|
|
467
|
+
severity="error",
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# Construct file path relative to repo
|
|
472
|
+
file_path = self.repo_path / self.finding.file_path
|
|
473
|
+
if not file_path.exists():
|
|
474
|
+
self.notify(f"File not found: {self.finding.file_path}", severity="error")
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
# Detect editor type and build appropriate command
|
|
478
|
+
editor_config = detect_editor_config(editor)
|
|
479
|
+
|
|
480
|
+
# Log editor invocation (ASVS V16.7.1)
|
|
481
|
+
logger.info(
|
|
482
|
+
"editor_opened",
|
|
483
|
+
extra={
|
|
484
|
+
"editor": editor,
|
|
485
|
+
"editor_type": editor_config.syntax_type,
|
|
486
|
+
"file": self.finding.file_path,
|
|
487
|
+
"line": self.finding.line,
|
|
488
|
+
},
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# ASVS V14.2.1: Use list args (not shell=True) to prevent injection
|
|
492
|
+
cmd = build_editor_command(editor_path, file_path, self.finding.line, editor_config)
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
# Suspend TUI, run editor, then resume
|
|
496
|
+
with self.app.suspend():
|
|
497
|
+
# S603: subprocess with validated input (editor_path checked via shutil.which)
|
|
498
|
+
result = subprocess.run(cmd, check=False) # noqa: S603
|
|
499
|
+
if result.returncode != 0:
|
|
500
|
+
self.notify(
|
|
501
|
+
f"Editor exited with code {result.returncode}",
|
|
502
|
+
severity="warning",
|
|
503
|
+
)
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.error("editor_failed", extra={"error": str(e)})
|
|
506
|
+
self.notify(f"Failed to open editor: {e}", severity="error")
|
|
507
|
+
|
|
508
|
+
def action_expand_context(self) -> None:
|
|
509
|
+
"""Expand code context by 10 lines (addresses keyhole effect)."""
|
|
510
|
+
self.context_lines = min(100, self.context_lines + 10)
|
|
511
|
+
self._refresh_code_context()
|
|
512
|
+
self.notify(f"Context expanded to {self.context_lines} lines", severity="information")
|
|
513
|
+
|
|
514
|
+
def action_shrink_context(self) -> None:
|
|
515
|
+
"""Shrink code context by 10 lines."""
|
|
516
|
+
self.context_lines = max(5, self.context_lines - 10)
|
|
517
|
+
self._refresh_code_context()
|
|
518
|
+
self.notify(f"Context shrunk to {self.context_lines} lines", severity="information")
|
|
519
|
+
|
|
520
|
+
def _refresh_code_context(self) -> None:
|
|
521
|
+
"""Refresh code context display with new context_lines setting."""
|
|
522
|
+
import logging
|
|
523
|
+
|
|
524
|
+
logger = logging.getLogger(__name__)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
# Update the prompt builder's context lines
|
|
528
|
+
self._code_extractor._prompt_builder.context_lines = self.context_lines
|
|
529
|
+
|
|
530
|
+
# Re-render code context
|
|
531
|
+
code_widget = self._render_code_context()
|
|
532
|
+
if code_widget:
|
|
533
|
+
# Find and update the existing widget
|
|
534
|
+
try:
|
|
535
|
+
old_widget = self.query_one("#code-context-display")
|
|
536
|
+
# Replace content by removing old and adding new
|
|
537
|
+
old_widget.remove()
|
|
538
|
+
# Find the label and insert after it
|
|
539
|
+
container = self.query_one("#detail-container")
|
|
540
|
+
container.mount(code_widget)
|
|
541
|
+
except Exception as e:
|
|
542
|
+
# Widget not found or couldn't refresh
|
|
543
|
+
logger.debug("code_context_refresh_failed", extra={"error": str(e)})
|
|
544
|
+
except Exception as e:
|
|
545
|
+
# Refresh failed, log but don't crash
|
|
546
|
+
logger.debug("code_context_update_failed", extra={"error": str(e)})
|
|
547
|
+
|
|
548
|
+
def _render_code_context(self) -> Static | None:
|
|
549
|
+
"""Render code context with syntax highlighting.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Static widget with Syntax-highlighted code, or None if unavailable.
|
|
553
|
+
|
|
554
|
+
Security:
|
|
555
|
+
- File size limit: 10MB (ASVS V10.3.3)
|
|
556
|
+
- Path validation: must be within repo_path (ASVS V5.3.3)
|
|
557
|
+
- Error sanitization: no full paths in errors (ASVS V7.4.1)
|
|
558
|
+
- Sensitive file blocking (ASVS V8.3.4)
|
|
559
|
+
"""
|
|
560
|
+
if not self.finding.file_path or not self.finding.line:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
context = self._code_extractor.extract(self.finding.file_path, self.finding.line)
|
|
564
|
+
if not context:
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
if context.error:
|
|
568
|
+
return Static(f"Code unavailable: {context.error}", classes="error-message")
|
|
569
|
+
|
|
570
|
+
# Create syntax-highlighted code display
|
|
571
|
+
try:
|
|
572
|
+
syntax = Syntax(
|
|
573
|
+
context.code,
|
|
574
|
+
context.language,
|
|
575
|
+
line_numbers=False, # We already have line numbers in the context
|
|
576
|
+
theme="monokai",
|
|
577
|
+
word_wrap=False,
|
|
578
|
+
)
|
|
579
|
+
return Static(syntax, id="code-context-display")
|
|
580
|
+
except Exception:
|
|
581
|
+
# Fallback to plain text if syntax highlighting fails
|
|
582
|
+
return Static(context.code, id="code-context-display")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kekkai-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: Terminal UI for Trivy/Semgrep/Gitleaks. Local-first security triage.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -14,19 +14,20 @@ Requires-Dist: jinja2>=3.1.6
|
|
|
14
14
|
<img src="https://raw.githubusercontent.com/kademoslabs/assets/main/logos/kekkai-slim.png" alt="Kekkai CLI Logo" width="250"/>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
|
-
<p align="center"><strong>
|
|
17
|
+
<p align="center"><strong>Interactive security triage in the terminal.</strong></p>
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
20
20
|
<img src="https://img.shields.io/github/actions/workflow/status/kademoslabs/kekkai/docker-publish.yml?logo=github"/>
|
|
21
21
|
<img src="https://img.shields.io/circleci/build/github/kademoslabs/kekkai?logo=circleci"/>
|
|
22
|
-
<img src="https://img.shields.io/pypi/v/kekkai-cli
|
|
22
|
+
<img alt="PyPI - Version" src="https://img.shields.io/pypi/v/kekkai-cli">
|
|
23
|
+
|
|
23
24
|
</p>
|
|
24
25
|
|
|
25
26
|
---
|
|
26
27
|
|
|
27
28
|
# Kekkai
|
|
28
29
|
|
|
29
|
-
**
|
|
30
|
+
**Stop parsing JSON.**
|
|
30
31
|
|
|
31
32
|
Kekkai is a small open-source CLI that wraps existing security scanners (Trivy, Semgrep, Gitleaks) and focuses on the part that tends to be slow and frustrating: reviewing and triaging results.
|
|
32
33
|
|
|
@@ -73,6 +74,17 @@ kekkai triage
|
|
|
73
74
|
# Interactive TUI to review findings with keyboard navigation
|
|
74
75
|
```
|
|
75
76
|
|
|
77
|
+
### ⚡️ Auto-Install (Pre-commit)
|
|
78
|
+
|
|
79
|
+
Add this to your `.pre-commit-config.yaml` to scan on every commit:
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
- repo: [https://github.com/kademoslabs/kekkai](https://github.com/kademoslabs/kekkai)
|
|
83
|
+
rev: v2.0.1
|
|
84
|
+
hooks:
|
|
85
|
+
- id: kekkai-scan
|
|
86
|
+
```
|
|
87
|
+
|
|
76
88
|
No signup, no cloud service required.
|
|
77
89
|
|
|
78
90
|
---
|
|
@@ -114,34 +126,11 @@ kekkai triage
|
|
|
114
126
|
|
|
115
127
|
---
|
|
116
128
|
|
|
117
|
-
### CI/CD
|
|
118
|
-
|
|
119
|
-
Break builds on severity thresholds.
|
|
120
|
-
|
|
121
|
-
Kekkai can be used as a CI gate based on severity thresholds.
|
|
129
|
+
### 🚦 CI/CD in 1 Second
|
|
122
130
|
|
|
131
|
+
Don't write YAML. Run this in your repo:
|
|
123
132
|
```bash
|
|
124
|
-
|
|
125
|
-
kekkai scan --ci --fail-on high
|
|
126
|
-
|
|
127
|
-
# Fail only on critical
|
|
128
|
-
kekkai scan --ci --fail-on critical
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
**Exit Codes:**
|
|
132
|
-
| Code | Meaning |
|
|
133
|
-
|------|---------|
|
|
134
|
-
| 0 | No findings above threshold |
|
|
135
|
-
| 1 | Findings exceed threshold |
|
|
136
|
-
| 2 | Scanner error |
|
|
137
|
-
|
|
138
|
-
**GitHub Actions Example:**
|
|
139
|
-
|
|
140
|
-
```yaml
|
|
141
|
-
- name: Security Scan
|
|
142
|
-
run: |
|
|
143
|
-
pipx install kekkai-cli
|
|
144
|
-
kekkai scan --ci --fail-on high
|
|
133
|
+
kekkai init --ci
|
|
145
134
|
```
|
|
146
135
|
|
|
147
136
|
[Full CI Documentation →](docs/ci/ci-mode.md)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
kekkai/__init__.py,sha256=_VrBvJRyqHiXs31S8HOhATk_O2iy-ac0_9X7rHH75j8,143
|
|
2
|
-
kekkai/cli.py,sha256=
|
|
2
|
+
kekkai/cli.py,sha256=Mua85FZFkpEmPtP3IUFh1sKkBsQLllln_Qe-ju5okgw,68479
|
|
3
3
|
kekkai/config.py,sha256=LE7bKsmv5dim5KnZya0V7_LtviNQ1V0pMN_6FyAsMpc,13084
|
|
4
4
|
kekkai/dojo.py,sha256=erLdTMOioTyzVhXYW8xgdbU5Ro-KQx1OcTQN7_zemmY,18634
|
|
5
5
|
kekkai/dojo_import.py,sha256=D0ZQM_0JYHqUqJA3l4nKD-RkpvcOcgj-4zv59HRcQ6k,7274
|
|
6
6
|
kekkai/manifest.py,sha256=Ph5xGDKuVxMW1GVIisRhxUelaiVZQe-W5sZWsq4lHqs,1887
|
|
7
|
-
kekkai/output.py,sha256=
|
|
7
|
+
kekkai/output.py,sha256=1T077PHznM8yfDWycfihvGTGrrpymoAkaHlMr8hAjkk,5426
|
|
8
8
|
kekkai/paths.py,sha256=EcyG3CEOQFQygowu7O5Mp85dKkXWWvnm1h0j_BetGxY,1190
|
|
9
9
|
kekkai/policy.py,sha256=0XCUH-SbnO1PsM-exjSFHYHRnLkiNa50QfkyPakwNko,9792
|
|
10
10
|
kekkai/runner.py,sha256=MBFUiJ4sSVEGNbJ6cv-8p1WHaHqjio6yWEfr_K4GuTs,2037
|
|
@@ -58,14 +58,16 @@ kekkai/threatflow/model_adapter.py,sha256=Vl0wBWvBUxEGTmFghjwpp-N7Zt3qkpUSxrPVjK
|
|
|
58
58
|
kekkai/threatflow/prompts.py,sha256=lgbj7FJ1c3UYj4ofGnlLoRmywYBfdAKY0QEHmIB_JFw,8525
|
|
59
59
|
kekkai/threatflow/redaction.py,sha256=mGUcNQB6YPVKArtMrEYcXCWslgUiCkloiowY9IlZ1iY,7622
|
|
60
60
|
kekkai/threatflow/sanitizer.py,sha256=uQsxYZ5VDXutZoj-WMl7fo5T07uHuQZqgVzoVMoaKec,22688
|
|
61
|
-
kekkai/triage/__init__.py,sha256=
|
|
62
|
-
kekkai/triage/app.py,sha256=
|
|
61
|
+
kekkai/triage/__init__.py,sha256=lUDFJqQk0EjQD2ZP6s3WQnZ8R-KGds8_W7RmFzBDPN8,2463
|
|
62
|
+
kekkai/triage/app.py,sha256=WKvT00UDO2ywVgahLlHAcqB-OnPN05Ps_FKoN6RY0lc,5615
|
|
63
63
|
kekkai/triage/audit.py,sha256=UVaSKKC6tZkHxEoMcnIZkMOT_ngj7QzHWYuDAHas_sc,5842
|
|
64
|
-
kekkai/triage/
|
|
64
|
+
kekkai/triage/code_context.py,sha256=kmAkQaxgGIxzf2n5s9vkwOBetmuZrQhoDuygxmU9cjg,11311
|
|
65
|
+
kekkai/triage/editor_support.py,sha256=IpOe4KXUdxKBAQMoCWGTNRK2sGYRpuVySiHQRLm4ugc,5497
|
|
66
|
+
kekkai/triage/fix_screen.py,sha256=jL0ZXoNKBvkhrnWxutPrviMRL5iacJ19qpxX3hKEZvc,8843
|
|
65
67
|
kekkai/triage/ignore.py,sha256=uBKM7zKyzORj9LJ5AAnoYWZQTRy57P0ZofSapiDWcfI,7305
|
|
66
68
|
kekkai/triage/loader.py,sha256=vywhS8fcre7PiBX3H2CpKXFxzvO7LcDnIHIB0kzG3R4,5850
|
|
67
69
|
kekkai/triage/models.py,sha256=nRmWtELMqHWHX1NqZ2upH2ZAJVeBxa3Wh8f3kkB9WYo,5384
|
|
68
|
-
kekkai/triage/screens.py,sha256=
|
|
70
|
+
kekkai/triage/screens.py,sha256=it253GNli37S2CkijbVfoxmiobEV3M3-FYdqXhp1tWk,20575
|
|
69
71
|
kekkai/triage/widgets.py,sha256=eOF6Qoo5uBqjxiEkbpgcO1tbIOGBQBKn75wP9Jw_AaE,4733
|
|
70
72
|
kekkai_core/__init__.py,sha256=gREN4oarM0azTkSTWTnlDnPZGgv1msai2Deq9Frj3gc,122
|
|
71
73
|
kekkai_core/redaction.py,sha256=EeWYPjAs2hIXlLKGmGn_PRdK08G4KcOBmbRCoFklbHc,2893
|
|
@@ -85,8 +87,8 @@ kekkai_core/windows/chocolatey.py,sha256=tF5S5eN-HeENRt6yQ4TZgwng0oRMX_ScskQ3-eb
|
|
|
85
87
|
kekkai_core/windows/installer.py,sha256=MePAywHH3JTIAENv52XtkUMOGqmYqZqkH77VW5PST8o,6945
|
|
86
88
|
kekkai_core/windows/scoop.py,sha256=lvothICrAoB3lGfkvhqVeNTB50eMmVGA0BE7JNCfHdI,5284
|
|
87
89
|
kekkai_core/windows/validators.py,sha256=45xUuAbHcKc0WLIZ-0rByPeDD88MAV8KvopngyYBHpQ,6525
|
|
88
|
-
kekkai_cli-2.
|
|
89
|
-
kekkai_cli-2.
|
|
90
|
-
kekkai_cli-2.
|
|
91
|
-
kekkai_cli-2.
|
|
92
|
-
kekkai_cli-2.
|
|
90
|
+
kekkai_cli-2.2.1.dist-info/METADATA,sha256=rFe0gsYLeFJPliwHjenTd3jk6VCsB0qlwCkO6OAzxJo,7865
|
|
91
|
+
kekkai_cli-2.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
92
|
+
kekkai_cli-2.2.1.dist-info/entry_points.txt,sha256=MBV1OIfxJmT2oJvzeeFKIH1eh8M9kKAn7JqFBeuMfWA,43
|
|
93
|
+
kekkai_cli-2.2.1.dist-info/top_level.txt,sha256=wWwh7GGPaUjcaCRmt70ueL3WQoQbeGa5L0T0hgOh-MY,19
|
|
94
|
+
kekkai_cli-2.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|