kekkai-cli 1.0.0__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 (90) hide show
  1. kekkai/__init__.py +7 -0
  2. kekkai/cli.py +1038 -0
  3. kekkai/config.py +403 -0
  4. kekkai/dojo.py +419 -0
  5. kekkai/dojo_import.py +213 -0
  6. kekkai/github/__init__.py +16 -0
  7. kekkai/github/commenter.py +198 -0
  8. kekkai/github/models.py +56 -0
  9. kekkai/github/sanitizer.py +112 -0
  10. kekkai/installer/__init__.py +39 -0
  11. kekkai/installer/errors.py +23 -0
  12. kekkai/installer/extract.py +161 -0
  13. kekkai/installer/manager.py +252 -0
  14. kekkai/installer/manifest.py +189 -0
  15. kekkai/installer/verify.py +86 -0
  16. kekkai/manifest.py +77 -0
  17. kekkai/output.py +218 -0
  18. kekkai/paths.py +46 -0
  19. kekkai/policy.py +326 -0
  20. kekkai/runner.py +70 -0
  21. kekkai/scanners/__init__.py +67 -0
  22. kekkai/scanners/backends/__init__.py +14 -0
  23. kekkai/scanners/backends/base.py +73 -0
  24. kekkai/scanners/backends/docker.py +178 -0
  25. kekkai/scanners/backends/native.py +240 -0
  26. kekkai/scanners/base.py +110 -0
  27. kekkai/scanners/container.py +144 -0
  28. kekkai/scanners/falco.py +237 -0
  29. kekkai/scanners/gitleaks.py +237 -0
  30. kekkai/scanners/semgrep.py +227 -0
  31. kekkai/scanners/trivy.py +246 -0
  32. kekkai/scanners/url_policy.py +163 -0
  33. kekkai/scanners/zap.py +340 -0
  34. kekkai/threatflow/__init__.py +94 -0
  35. kekkai/threatflow/artifacts.py +476 -0
  36. kekkai/threatflow/chunking.py +361 -0
  37. kekkai/threatflow/core.py +438 -0
  38. kekkai/threatflow/mermaid.py +374 -0
  39. kekkai/threatflow/model_adapter.py +491 -0
  40. kekkai/threatflow/prompts.py +277 -0
  41. kekkai/threatflow/redaction.py +228 -0
  42. kekkai/threatflow/sanitizer.py +643 -0
  43. kekkai/triage/__init__.py +33 -0
  44. kekkai/triage/app.py +168 -0
  45. kekkai/triage/audit.py +203 -0
  46. kekkai/triage/ignore.py +269 -0
  47. kekkai/triage/models.py +185 -0
  48. kekkai/triage/screens.py +341 -0
  49. kekkai/triage/widgets.py +169 -0
  50. kekkai_cli-1.0.0.dist-info/METADATA +135 -0
  51. kekkai_cli-1.0.0.dist-info/RECORD +90 -0
  52. kekkai_cli-1.0.0.dist-info/WHEEL +5 -0
  53. kekkai_cli-1.0.0.dist-info/entry_points.txt +3 -0
  54. kekkai_cli-1.0.0.dist-info/top_level.txt +3 -0
  55. kekkai_core/__init__.py +3 -0
  56. kekkai_core/ci/__init__.py +11 -0
  57. kekkai_core/ci/benchmarks.py +354 -0
  58. kekkai_core/ci/metadata.py +104 -0
  59. kekkai_core/ci/validators.py +92 -0
  60. kekkai_core/docker/__init__.py +17 -0
  61. kekkai_core/docker/metadata.py +153 -0
  62. kekkai_core/docker/sbom.py +173 -0
  63. kekkai_core/docker/security.py +158 -0
  64. kekkai_core/docker/signing.py +135 -0
  65. kekkai_core/redaction.py +84 -0
  66. kekkai_core/slsa/__init__.py +13 -0
  67. kekkai_core/slsa/verify.py +121 -0
  68. kekkai_core/windows/__init__.py +29 -0
  69. kekkai_core/windows/chocolatey.py +335 -0
  70. kekkai_core/windows/installer.py +256 -0
  71. kekkai_core/windows/scoop.py +165 -0
  72. kekkai_core/windows/validators.py +220 -0
  73. portal/__init__.py +19 -0
  74. portal/api.py +155 -0
  75. portal/auth.py +103 -0
  76. portal/enterprise/__init__.py +32 -0
  77. portal/enterprise/audit.py +435 -0
  78. portal/enterprise/licensing.py +342 -0
  79. portal/enterprise/rbac.py +276 -0
  80. portal/enterprise/saml.py +595 -0
  81. portal/ops/__init__.py +53 -0
  82. portal/ops/backup.py +553 -0
  83. portal/ops/log_shipper.py +469 -0
  84. portal/ops/monitoring.py +517 -0
  85. portal/ops/restore.py +469 -0
  86. portal/ops/secrets.py +408 -0
  87. portal/ops/upgrade.py +591 -0
  88. portal/tenants.py +340 -0
  89. portal/uploads.py +259 -0
  90. portal/web.py +384 -0
@@ -0,0 +1,185 @@
1
+ """Triage data models for security finding review.
2
+
3
+ Provides dataclasses for triage state management with security-focused
4
+ validation and immutable decision records.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from enum import Enum
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ __all__ = [
18
+ "TriageState",
19
+ "TriageDecision",
20
+ "FindingEntry",
21
+ "Severity",
22
+ ]
23
+
24
+
25
+ class Severity(Enum):
26
+ """Finding severity levels."""
27
+
28
+ CRITICAL = "critical"
29
+ HIGH = "high"
30
+ MEDIUM = "medium"
31
+ LOW = "low"
32
+ INFO = "info"
33
+
34
+
35
+ class TriageState(Enum):
36
+ """States for a triaged finding."""
37
+
38
+ PENDING = "pending"
39
+ FALSE_POSITIVE = "false_positive"
40
+ CONFIRMED = "confirmed"
41
+ DEFERRED = "deferred"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TriageDecision:
46
+ """Immutable record of a triage decision.
47
+
48
+ Attributes:
49
+ finding_id: Unique identifier for the finding.
50
+ state: The triage state assigned.
51
+ reason: Optional reason/notes for the decision.
52
+ timestamp: When the decision was made (UTC).
53
+ user: Username or identifier of the triager.
54
+ ignore_pattern: Generated ignore pattern if applicable.
55
+ """
56
+
57
+ finding_id: str
58
+ state: TriageState
59
+ reason: str = ""
60
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
61
+ user: str = ""
62
+ ignore_pattern: str | None = None
63
+
64
+ def to_dict(self) -> dict[str, str | None]:
65
+ """Convert to dictionary for JSON serialization."""
66
+ return {
67
+ "finding_id": self.finding_id,
68
+ "state": self.state.value,
69
+ "reason": self.reason,
70
+ "timestamp": self.timestamp,
71
+ "user": self.user,
72
+ "ignore_pattern": self.ignore_pattern,
73
+ }
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, str | None]) -> TriageDecision:
77
+ """Create from dictionary."""
78
+ return cls(
79
+ finding_id=str(data.get("finding_id", "")),
80
+ state=TriageState(data.get("state", "pending")),
81
+ reason=str(data.get("reason", "")),
82
+ timestamp=str(data.get("timestamp", "")),
83
+ user=str(data.get("user", "")),
84
+ ignore_pattern=data.get("ignore_pattern"),
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class FindingEntry:
90
+ """A security finding entry for triage.
91
+
92
+ Attributes:
93
+ id: Unique finding identifier.
94
+ title: Finding title/summary.
95
+ severity: Severity level.
96
+ scanner: Scanner that produced the finding.
97
+ file_path: File path where finding was detected.
98
+ line: Line number if applicable.
99
+ description: Detailed description.
100
+ rule_id: Scanner rule identifier.
101
+ state: Current triage state.
102
+ notes: User notes for this finding.
103
+ """
104
+
105
+ id: str
106
+ title: str
107
+ severity: Severity
108
+ scanner: str
109
+ file_path: str = ""
110
+ line: int | None = None
111
+ description: str = ""
112
+ rule_id: str = ""
113
+ state: TriageState = TriageState.PENDING
114
+ notes: str = ""
115
+
116
+ def to_dict(self) -> dict[str, str | int | None]:
117
+ """Convert to dictionary for JSON serialization."""
118
+ return {
119
+ "id": self.id,
120
+ "title": self.title,
121
+ "severity": self.severity.value,
122
+ "scanner": self.scanner,
123
+ "file_path": self.file_path,
124
+ "line": self.line,
125
+ "description": self.description,
126
+ "rule_id": self.rule_id,
127
+ "state": self.state.value,
128
+ "notes": self.notes,
129
+ }
130
+
131
+ @classmethod
132
+ def from_dict(cls, data: dict[str, str | int | None]) -> FindingEntry:
133
+ """Create from dictionary (e.g., from scan results JSON)."""
134
+ severity_str = str(data.get("severity", "info")).lower()
135
+ try:
136
+ severity = Severity(severity_str)
137
+ except ValueError:
138
+ severity = Severity.INFO
139
+
140
+ state_str = str(data.get("state", "pending")).lower()
141
+ try:
142
+ state = TriageState(state_str)
143
+ except ValueError:
144
+ state = TriageState.PENDING
145
+
146
+ line_val = data.get("line")
147
+ line = int(line_val) if line_val is not None else None
148
+
149
+ return cls(
150
+ id=str(data.get("id", "")),
151
+ title=str(data.get("title", "")),
152
+ severity=severity,
153
+ scanner=str(data.get("scanner", "")),
154
+ file_path=str(data.get("file_path", "")),
155
+ line=line,
156
+ description=str(data.get("description", "")),
157
+ rule_id=str(data.get("rule_id", "")),
158
+ state=state,
159
+ notes=str(data.get("notes", "")),
160
+ )
161
+
162
+ def generate_ignore_pattern(self) -> str:
163
+ """Generate an ignore pattern for this finding.
164
+
165
+ Returns:
166
+ A pattern suitable for .kekkaiignore file.
167
+ """
168
+ parts = [self.scanner]
169
+ if self.rule_id:
170
+ parts.append(self.rule_id)
171
+ if self.file_path:
172
+ parts.append(self.file_path)
173
+ return ":".join(parts)
174
+
175
+
176
+ def load_findings_from_json(data: Sequence[dict[str, str | int | None]]) -> list[FindingEntry]:
177
+ """Load findings from JSON data.
178
+
179
+ Args:
180
+ data: List of finding dictionaries.
181
+
182
+ Returns:
183
+ List of FindingEntry objects.
184
+ """
185
+ return [FindingEntry.from_dict(item) for item in data]
@@ -0,0 +1,341 @@
1
+ """Textual screens for triage TUI.
2
+
3
+ Provides screen components for finding list and detail views
4
+ with keyboard navigation and action handling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from rich.text import Text
12
+ from textual.app import ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Vertical, VerticalScroll
15
+ from textual.screen import Screen
16
+ from textual.widgets import Footer, Header, Label, Static, TextArea
17
+
18
+ from .models import TriageState
19
+ from .widgets import FindingCard, sanitize_display
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable
23
+
24
+ from .models import FindingEntry
25
+
26
+ __all__ = [
27
+ "FindingListScreen",
28
+ "FindingDetailScreen",
29
+ ]
30
+
31
+
32
+ class FindingListScreen(Screen[None]):
33
+ """Screen displaying paginated list of findings.
34
+
35
+ Bindings:
36
+ j/down: Move to next finding
37
+ k/up: Move to previous finding
38
+ enter: View finding details
39
+ f: Mark as false positive
40
+ c: Mark as confirmed
41
+ d: Mark as deferred
42
+ s: Save ignore file
43
+ q: Quit
44
+ """
45
+
46
+ BINDINGS = [
47
+ Binding("j", "cursor_down", "Next"),
48
+ Binding("k", "cursor_up", "Previous"),
49
+ Binding("down", "cursor_down", "Next", show=False),
50
+ Binding("up", "cursor_up", "Previous", show=False),
51
+ Binding("enter", "view_detail", "View"),
52
+ Binding("f", "mark_false_positive", "False Positive"),
53
+ Binding("c", "mark_confirmed", "Confirmed"),
54
+ Binding("d", "mark_deferred", "Deferred"),
55
+ Binding("ctrl+s", "save", "Save"),
56
+ Binding("q", "quit", "Quit"),
57
+ ]
58
+
59
+ DEFAULT_CSS = """
60
+ FindingListScreen {
61
+ layout: vertical;
62
+ }
63
+ #finding-list {
64
+ height: 1fr;
65
+ padding: 1;
66
+ }
67
+ #status-bar {
68
+ dock: bottom;
69
+ height: 3;
70
+ padding: 1;
71
+ background: $surface;
72
+ border-top: solid $primary;
73
+ }
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ findings: list[FindingEntry],
79
+ on_state_change: Callable[[int, TriageState], None] | None = None,
80
+ on_save: Callable[[], None] | None = None,
81
+ name: str | None = None,
82
+ id: str | None = None,
83
+ ) -> None:
84
+ super().__init__(name=name, id=id)
85
+ self.findings = findings
86
+ self.selected_index = 0
87
+ self.on_state_change = on_state_change
88
+ self.on_save = on_save
89
+ self._cards: list[FindingCard] = []
90
+
91
+ def compose(self) -> ComposeResult:
92
+ yield Header()
93
+ with VerticalScroll(id="finding-list"):
94
+ for i, finding in enumerate(self.findings):
95
+ card = FindingCard(finding, selected=(i == 0), id=f"card-{i}")
96
+ self._cards.append(card)
97
+ yield card
98
+ yield Static(self._status_text(), id="status-bar")
99
+ yield Footer()
100
+
101
+ def _status_text(self) -> Text:
102
+ """Generate status bar text."""
103
+ total = len(self.findings)
104
+ if total == 0:
105
+ return Text("No findings to triage", style="dim")
106
+
107
+ counts = {s: 0 for s in TriageState}
108
+ for f in self.findings:
109
+ counts[f.state] += 1
110
+
111
+ text = Text()
112
+ text.append(f"Total: {total} | ", style="bold")
113
+ text.append(f"Pending: {counts[TriageState.PENDING]} | ")
114
+ text.append(f"FP: {counts[TriageState.FALSE_POSITIVE]} | ", style="green")
115
+ text.append(f"Confirmed: {counts[TriageState.CONFIRMED]} | ", style="red")
116
+ text.append(f"Deferred: {counts[TriageState.DEFERRED]}", style="yellow")
117
+ return text
118
+
119
+ def _update_status(self) -> None:
120
+ """Update status bar."""
121
+ status_bar = self.query_one("#status-bar", Static)
122
+ status_bar.update(self._status_text())
123
+
124
+ def _update_selection(self, new_index: int) -> None:
125
+ """Update visual selection."""
126
+ if not self._cards:
127
+ return
128
+
129
+ old_index = self.selected_index
130
+ self.selected_index = max(0, min(new_index, len(self._cards) - 1))
131
+
132
+ if old_index < len(self._cards):
133
+ self._cards[old_index].set_selected(False)
134
+ if self.selected_index < len(self._cards):
135
+ self._cards[self.selected_index].set_selected(True)
136
+ self._cards[self.selected_index].scroll_visible()
137
+
138
+ def action_cursor_down(self) -> None:
139
+ """Move selection down."""
140
+ self._update_selection(self.selected_index + 1)
141
+
142
+ def action_cursor_up(self) -> None:
143
+ """Move selection up."""
144
+ self._update_selection(self.selected_index - 1)
145
+
146
+ def action_view_detail(self) -> None:
147
+ """Open detail view for selected finding."""
148
+ if not self.findings:
149
+ return
150
+ finding = self.findings[self.selected_index]
151
+ self.app.push_screen(
152
+ FindingDetailScreen(
153
+ finding,
154
+ on_state_change=self._handle_detail_state_change,
155
+ )
156
+ )
157
+
158
+ def _handle_detail_state_change(self, state: TriageState, notes: str) -> None:
159
+ """Handle state change from detail screen."""
160
+ if self.selected_index < len(self.findings):
161
+ self.findings[self.selected_index].state = state
162
+ self.findings[self.selected_index].notes = notes
163
+ self._cards[self.selected_index].finding = self.findings[self.selected_index]
164
+ self._cards[self.selected_index].refresh()
165
+ self._update_status()
166
+ if self.on_state_change:
167
+ self.on_state_change(self.selected_index, state)
168
+
169
+ def _mark_state(self, state: TriageState) -> None:
170
+ """Mark selected finding with given state."""
171
+ if not self.findings:
172
+ return
173
+ self.findings[self.selected_index].state = state
174
+ self._cards[self.selected_index].finding = self.findings[self.selected_index]
175
+ self._cards[self.selected_index].refresh()
176
+ self._update_status()
177
+ if self.on_state_change:
178
+ self.on_state_change(self.selected_index, state)
179
+
180
+ def action_mark_false_positive(self) -> None:
181
+ """Mark as false positive."""
182
+ self._mark_state(TriageState.FALSE_POSITIVE)
183
+
184
+ def action_mark_confirmed(self) -> None:
185
+ """Mark as confirmed."""
186
+ self._mark_state(TriageState.CONFIRMED)
187
+
188
+ def action_mark_deferred(self) -> None:
189
+ """Mark as deferred."""
190
+ self._mark_state(TriageState.DEFERRED)
191
+
192
+ def action_save(self) -> None:
193
+ """Save ignore file."""
194
+ if self.on_save:
195
+ self.on_save()
196
+ self.notify("Ignore file saved", severity="information")
197
+
198
+ def action_quit(self) -> None:
199
+ """Quit the application."""
200
+ self.app.exit()
201
+
202
+
203
+ class FindingDetailScreen(Screen[None]):
204
+ """Screen showing full finding details with notes editing.
205
+
206
+ Bindings:
207
+ f: Mark as false positive
208
+ c: Mark as confirmed
209
+ d: Mark as deferred
210
+ escape: Go back
211
+ """
212
+
213
+ BINDINGS = [
214
+ Binding("f", "mark_false_positive", "False Positive"),
215
+ Binding("c", "mark_confirmed", "Confirmed"),
216
+ Binding("d", "mark_deferred", "Deferred"),
217
+ Binding("escape", "go_back", "Back"),
218
+ ]
219
+
220
+ DEFAULT_CSS = """
221
+ FindingDetailScreen {
222
+ layout: vertical;
223
+ }
224
+ #detail-container {
225
+ padding: 2;
226
+ }
227
+ #detail-header {
228
+ height: auto;
229
+ margin-bottom: 1;
230
+ }
231
+ #detail-content {
232
+ height: 1fr;
233
+ padding: 1;
234
+ border: solid $primary;
235
+ }
236
+ #notes-area {
237
+ height: 8;
238
+ margin-top: 1;
239
+ border: solid $secondary;
240
+ }
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ finding: FindingEntry,
246
+ on_state_change: Callable[[TriageState, str], None] | None = None,
247
+ name: str | None = None,
248
+ id: str | None = None,
249
+ ) -> None:
250
+ super().__init__(name=name, id=id)
251
+ self.finding = finding
252
+ self.on_state_change = on_state_change
253
+
254
+ def compose(self) -> ComposeResult:
255
+ yield Header()
256
+ with Vertical(id="detail-container"):
257
+ yield Static(self._header_text(), id="detail-header")
258
+ with VerticalScroll(id="detail-content"):
259
+ yield Static(self._detail_text())
260
+ yield Label("Notes (will be saved with decision):")
261
+ yield TextArea(self.finding.notes, id="notes-area")
262
+ yield Footer()
263
+
264
+ def _header_text(self) -> Text:
265
+ """Generate header with severity and title."""
266
+ from .widgets import SEVERITY_STYLES, STATE_LABELS, STATE_STYLES
267
+
268
+ text = Text()
269
+
270
+ sev_style = SEVERITY_STYLES.get(self.finding.severity.value, "dim")
271
+ text.append(f" {self.finding.severity.value.upper()} ", style=sev_style)
272
+ text.append(" ")
273
+
274
+ state_style = STATE_STYLES.get(self.finding.state.value, "dim")
275
+ state_label = STATE_LABELS.get(self.finding.state.value, "")
276
+ text.append(f"[{state_label}]", style=state_style)
277
+ text.append("\n\n")
278
+
279
+ title = sanitize_display(self.finding.title, max_length=100)
280
+ text.append(title, style="bold")
281
+
282
+ return text
283
+
284
+ def _detail_text(self) -> Text:
285
+ """Generate detail content."""
286
+ text = Text()
287
+
288
+ text.append("Scanner: ", style="bold")
289
+ text.append(sanitize_display(self.finding.scanner))
290
+ text.append("\n")
291
+
292
+ if self.finding.rule_id:
293
+ text.append("Rule ID: ", style="bold")
294
+ text.append(sanitize_display(self.finding.rule_id))
295
+ text.append("\n")
296
+
297
+ if self.finding.file_path:
298
+ text.append("File: ", style="bold")
299
+ text.append(sanitize_display(self.finding.file_path))
300
+ if self.finding.line:
301
+ text.append(f":{self.finding.line}")
302
+ text.append("\n")
303
+
304
+ text.append("\n")
305
+ text.append("Description:\n", style="bold")
306
+ description = sanitize_display(self.finding.description, max_length=2000)
307
+ text.append(description)
308
+
309
+ return text
310
+
311
+ def _get_notes(self) -> str:
312
+ """Get notes from text area."""
313
+ try:
314
+ notes_area = self.query_one("#notes-area", TextArea)
315
+ return notes_area.text
316
+ except Exception:
317
+ return ""
318
+
319
+ def _mark_and_close(self, state: TriageState) -> None:
320
+ """Mark state and close screen."""
321
+ self.finding.state = state
322
+ notes = self._get_notes()
323
+ if self.on_state_change:
324
+ self.on_state_change(state, notes)
325
+ self.app.pop_screen()
326
+
327
+ def action_mark_false_positive(self) -> None:
328
+ """Mark as false positive and go back."""
329
+ self._mark_and_close(TriageState.FALSE_POSITIVE)
330
+
331
+ def action_mark_confirmed(self) -> None:
332
+ """Mark as confirmed and go back."""
333
+ self._mark_and_close(TriageState.CONFIRMED)
334
+
335
+ def action_mark_deferred(self) -> None:
336
+ """Mark as deferred and go back."""
337
+ self._mark_and_close(TriageState.DEFERRED)
338
+
339
+ def action_go_back(self) -> None:
340
+ """Go back to list screen."""
341
+ self.app.pop_screen()
@@ -0,0 +1,169 @@
1
+ """Custom Textual widgets for triage TUI.
2
+
3
+ Provides security-focused widgets with content sanitization
4
+ and consistent styling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from rich.text import Text
12
+ from textual.widgets import Static
13
+
14
+ if TYPE_CHECKING:
15
+ from .models import FindingEntry, Severity, TriageState
16
+
17
+ __all__ = [
18
+ "SeverityBadge",
19
+ "FindingCard",
20
+ "StateBadge",
21
+ ]
22
+
23
+ SEVERITY_STYLES: dict[str, str] = {
24
+ "critical": "bold white on red",
25
+ "high": "bold white on dark_orange",
26
+ "medium": "bold black on yellow",
27
+ "low": "bold white on blue",
28
+ "info": "dim white on grey37",
29
+ }
30
+
31
+ STATE_STYLES: dict[str, str] = {
32
+ "pending": "dim white",
33
+ "false_positive": "green",
34
+ "confirmed": "red",
35
+ "deferred": "yellow",
36
+ }
37
+
38
+ STATE_LABELS: dict[str, str] = {
39
+ "pending": "Pending",
40
+ "false_positive": "False Positive",
41
+ "confirmed": "Confirmed",
42
+ "deferred": "Deferred",
43
+ }
44
+
45
+
46
+ def sanitize_display(text: str, max_length: int = 200) -> str:
47
+ """Sanitize text for terminal display.
48
+
49
+ Removes ANSI escape sequences and truncates to max length.
50
+
51
+ Args:
52
+ text: Text to sanitize.
53
+ max_length: Maximum length.
54
+
55
+ Returns:
56
+ Sanitized text.
57
+ """
58
+ import re
59
+
60
+ text = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", text)
61
+ text = text.replace("\n", " ").replace("\r", "")
62
+
63
+ if len(text) > max_length:
64
+ text = text[: max_length - 3] + "..."
65
+
66
+ return text
67
+
68
+
69
+ class SeverityBadge(Static):
70
+ """A badge displaying severity level with appropriate styling."""
71
+
72
+ def __init__(self, severity: Severity, name: str | None = None, id: str | None = None) -> None:
73
+ super().__init__(name=name, id=id)
74
+ self.severity = severity
75
+
76
+ def render(self) -> Text:
77
+ """Render the severity badge."""
78
+ style = SEVERITY_STYLES.get(self.severity.value, "dim")
79
+ label = f" {self.severity.value.upper()} "
80
+ return Text(label, style=style)
81
+
82
+
83
+ class StateBadge(Static):
84
+ """A badge displaying triage state."""
85
+
86
+ def __init__(self, state: TriageState, name: str | None = None, id: str | None = None) -> None:
87
+ super().__init__(name=name, id=id)
88
+ self.state = state
89
+
90
+ def render(self) -> Text:
91
+ """Render the state badge."""
92
+ style = STATE_STYLES.get(self.state.value, "dim")
93
+ label = STATE_LABELS.get(self.state.value, self.state.value)
94
+ return Text(f"[{label}]", style=style)
95
+
96
+
97
+ class FindingCard(Static):
98
+ """A card displaying a security finding summary.
99
+
100
+ Sanitizes all content before display to prevent terminal injection.
101
+ """
102
+
103
+ DEFAULT_CSS = """
104
+ FindingCard {
105
+ padding: 1;
106
+ margin: 0 0 1 0;
107
+ border: solid $primary;
108
+ background: $surface;
109
+ }
110
+ FindingCard:hover {
111
+ border: solid $secondary;
112
+ }
113
+ FindingCard.selected {
114
+ border: double $accent;
115
+ background: $surface-darken-1;
116
+ }
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ finding: FindingEntry,
122
+ selected: bool = False,
123
+ name: str | None = None,
124
+ id: str | None = None,
125
+ ) -> None:
126
+ super().__init__(name=name, id=id)
127
+ self.finding = finding
128
+ self.selected = selected
129
+ if selected:
130
+ self.add_class("selected")
131
+
132
+ def render(self) -> Text:
133
+ """Render the finding card."""
134
+ text = Text()
135
+
136
+ severity_style = SEVERITY_STYLES.get(self.finding.severity.value, "dim")
137
+ text.append(f" {self.finding.severity.value.upper()} ", style=severity_style)
138
+ text.append(" ")
139
+
140
+ state_style = STATE_STYLES.get(self.finding.state.value, "dim")
141
+ state_label = STATE_LABELS.get(self.finding.state.value, "")
142
+ text.append(f"[{state_label}]", style=state_style)
143
+ text.append("\n")
144
+
145
+ title = sanitize_display(self.finding.title, max_length=80)
146
+ text.append(title, style="bold")
147
+ text.append("\n")
148
+
149
+ scanner = sanitize_display(self.finding.scanner)
150
+ rule_id = sanitize_display(self.finding.rule_id)
151
+ text.append(f"Scanner: {scanner}", style="dim")
152
+ if rule_id:
153
+ text.append(f" | Rule: {rule_id}", style="dim")
154
+ text.append("\n")
155
+
156
+ if self.finding.file_path:
157
+ file_path = sanitize_display(self.finding.file_path, max_length=60)
158
+ line_info = f":{self.finding.line}" if self.finding.line else ""
159
+ text.append(f"File: {file_path}{line_info}", style="cyan")
160
+
161
+ return text
162
+
163
+ def set_selected(self, selected: bool) -> None:
164
+ """Update selection state."""
165
+ self.selected = selected
166
+ if selected:
167
+ self.add_class("selected")
168
+ else:
169
+ self.remove_class("selected")