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.
- kekkai/__init__.py +7 -0
- kekkai/cli.py +1038 -0
- kekkai/config.py +403 -0
- kekkai/dojo.py +419 -0
- kekkai/dojo_import.py +213 -0
- kekkai/github/__init__.py +16 -0
- kekkai/github/commenter.py +198 -0
- kekkai/github/models.py +56 -0
- kekkai/github/sanitizer.py +112 -0
- kekkai/installer/__init__.py +39 -0
- kekkai/installer/errors.py +23 -0
- kekkai/installer/extract.py +161 -0
- kekkai/installer/manager.py +252 -0
- kekkai/installer/manifest.py +189 -0
- kekkai/installer/verify.py +86 -0
- kekkai/manifest.py +77 -0
- kekkai/output.py +218 -0
- kekkai/paths.py +46 -0
- kekkai/policy.py +326 -0
- kekkai/runner.py +70 -0
- kekkai/scanners/__init__.py +67 -0
- kekkai/scanners/backends/__init__.py +14 -0
- kekkai/scanners/backends/base.py +73 -0
- kekkai/scanners/backends/docker.py +178 -0
- kekkai/scanners/backends/native.py +240 -0
- kekkai/scanners/base.py +110 -0
- kekkai/scanners/container.py +144 -0
- kekkai/scanners/falco.py +237 -0
- kekkai/scanners/gitleaks.py +237 -0
- kekkai/scanners/semgrep.py +227 -0
- kekkai/scanners/trivy.py +246 -0
- kekkai/scanners/url_policy.py +163 -0
- kekkai/scanners/zap.py +340 -0
- kekkai/threatflow/__init__.py +94 -0
- kekkai/threatflow/artifacts.py +476 -0
- kekkai/threatflow/chunking.py +361 -0
- kekkai/threatflow/core.py +438 -0
- kekkai/threatflow/mermaid.py +374 -0
- kekkai/threatflow/model_adapter.py +491 -0
- kekkai/threatflow/prompts.py +277 -0
- kekkai/threatflow/redaction.py +228 -0
- kekkai/threatflow/sanitizer.py +643 -0
- kekkai/triage/__init__.py +33 -0
- kekkai/triage/app.py +168 -0
- kekkai/triage/audit.py +203 -0
- kekkai/triage/ignore.py +269 -0
- kekkai/triage/models.py +185 -0
- kekkai/triage/screens.py +341 -0
- kekkai/triage/widgets.py +169 -0
- kekkai_cli-1.0.0.dist-info/METADATA +135 -0
- kekkai_cli-1.0.0.dist-info/RECORD +90 -0
- kekkai_cli-1.0.0.dist-info/WHEEL +5 -0
- kekkai_cli-1.0.0.dist-info/entry_points.txt +3 -0
- kekkai_cli-1.0.0.dist-info/top_level.txt +3 -0
- kekkai_core/__init__.py +3 -0
- kekkai_core/ci/__init__.py +11 -0
- kekkai_core/ci/benchmarks.py +354 -0
- kekkai_core/ci/metadata.py +104 -0
- kekkai_core/ci/validators.py +92 -0
- kekkai_core/docker/__init__.py +17 -0
- kekkai_core/docker/metadata.py +153 -0
- kekkai_core/docker/sbom.py +173 -0
- kekkai_core/docker/security.py +158 -0
- kekkai_core/docker/signing.py +135 -0
- kekkai_core/redaction.py +84 -0
- kekkai_core/slsa/__init__.py +13 -0
- kekkai_core/slsa/verify.py +121 -0
- kekkai_core/windows/__init__.py +29 -0
- kekkai_core/windows/chocolatey.py +335 -0
- kekkai_core/windows/installer.py +256 -0
- kekkai_core/windows/scoop.py +165 -0
- kekkai_core/windows/validators.py +220 -0
- portal/__init__.py +19 -0
- portal/api.py +155 -0
- portal/auth.py +103 -0
- portal/enterprise/__init__.py +32 -0
- portal/enterprise/audit.py +435 -0
- portal/enterprise/licensing.py +342 -0
- portal/enterprise/rbac.py +276 -0
- portal/enterprise/saml.py +595 -0
- portal/ops/__init__.py +53 -0
- portal/ops/backup.py +553 -0
- portal/ops/log_shipper.py +469 -0
- portal/ops/monitoring.py +517 -0
- portal/ops/restore.py +469 -0
- portal/ops/secrets.py +408 -0
- portal/ops/upgrade.py +591 -0
- portal/tenants.py +340 -0
- portal/uploads.py +259 -0
- portal/web.py +384 -0
kekkai/triage/models.py
ADDED
|
@@ -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]
|
kekkai/triage/screens.py
ADDED
|
@@ -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()
|
kekkai/triage/widgets.py
ADDED
|
@@ -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")
|