cyvest 0.1.0__py3-none-any.whl → 5.1.3__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.
- cyvest/__init__.py +48 -38
- cyvest/cli.py +487 -0
- cyvest/compare.py +318 -0
- cyvest/cyvest.py +1431 -0
- cyvest/investigation.py +1682 -0
- cyvest/io_rich.py +1153 -0
- cyvest/io_schema.py +35 -0
- cyvest/io_serialization.py +465 -0
- cyvest/io_visualization.py +358 -0
- cyvest/keys.py +237 -0
- cyvest/level_score_rules.py +78 -0
- cyvest/levels.py +175 -0
- cyvest/model.py +595 -0
- cyvest/model_enums.py +69 -0
- cyvest/model_schema.py +164 -0
- cyvest/proxies.py +595 -0
- cyvest/score.py +473 -0
- cyvest/shared.py +508 -0
- cyvest/stats.py +291 -0
- cyvest/ulid.py +36 -0
- cyvest-5.1.3.dist-info/METADATA +632 -0
- cyvest-5.1.3.dist-info/RECORD +24 -0
- {cyvest-0.1.0.dist-info → cyvest-5.1.3.dist-info}/WHEEL +1 -2
- cyvest-5.1.3.dist-info/entry_points.txt +3 -0
- cyvest/builder.py +0 -182
- cyvest/check_tree.py +0 -117
- cyvest/models.py +0 -785
- cyvest/observable_registry.py +0 -69
- cyvest/report_render.py +0 -306
- cyvest/report_serialization.py +0 -237
- cyvest/visitors.py +0 -332
- cyvest-0.1.0.dist-info/METADATA +0 -110
- cyvest-0.1.0.dist-info/RECORD +0 -13
- cyvest-0.1.0.dist-info/licenses/LICENSE +0 -21
- cyvest-0.1.0.dist-info/top_level.txt +0 -1
cyvest/io_rich.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rich console output for Cyvest investigations.
|
|
3
|
+
|
|
4
|
+
Provides formatted display of investigation results using the Rich library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Callable, Iterable
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from decimal import Decimal, InvalidOperation
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from rich.align import Align
|
|
16
|
+
from rich.console import Group
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.rule import Rule
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.tree import Tree
|
|
22
|
+
|
|
23
|
+
from cyvest.levels import Level, get_color_level, get_color_score, get_level_from_score, normalize_level
|
|
24
|
+
from cyvest.model import Observable, Relationship, RelationshipDirection, _format_score_decimal
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from cyvest.cyvest import Cyvest
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_exclude_levels(levels: Level | Iterable[Level]) -> set[Level]:
|
|
31
|
+
base_excluded: set[Level] = {Level.NONE}
|
|
32
|
+
if levels is None:
|
|
33
|
+
return base_excluded
|
|
34
|
+
if isinstance(levels, Level):
|
|
35
|
+
return base_excluded | {levels}
|
|
36
|
+
if isinstance(levels, str):
|
|
37
|
+
return base_excluded | {normalize_level(levels)}
|
|
38
|
+
|
|
39
|
+
collected = list(levels)
|
|
40
|
+
if not collected:
|
|
41
|
+
return set()
|
|
42
|
+
|
|
43
|
+
normalized: set[Level] = set()
|
|
44
|
+
for level in collected:
|
|
45
|
+
normalized.add(normalize_level(level) if isinstance(level, str) else level)
|
|
46
|
+
return base_excluded | normalized
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sort_key_by_score(item: Any) -> tuple[Decimal, str]:
|
|
50
|
+
score = getattr(item, "score", 0)
|
|
51
|
+
try:
|
|
52
|
+
decimal_score = Decimal(score)
|
|
53
|
+
except (TypeError, ValueError, InvalidOperation):
|
|
54
|
+
decimal_score = Decimal(0)
|
|
55
|
+
|
|
56
|
+
item_name = getattr(item, "check_name", "")
|
|
57
|
+
return (-decimal_score, item_name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_direction_symbol(rel: Relationship, reversed_edge: bool) -> str:
|
|
61
|
+
"""Return an arrow indicating direction relative to traversal."""
|
|
62
|
+
direction = rel.direction
|
|
63
|
+
if isinstance(direction, str):
|
|
64
|
+
try:
|
|
65
|
+
direction = RelationshipDirection(direction)
|
|
66
|
+
except ValueError:
|
|
67
|
+
direction = RelationshipDirection.OUTBOUND
|
|
68
|
+
|
|
69
|
+
symbol_map = {
|
|
70
|
+
RelationshipDirection.OUTBOUND: "→",
|
|
71
|
+
RelationshipDirection.INBOUND: "←",
|
|
72
|
+
RelationshipDirection.BIDIRECTIONAL: "↔",
|
|
73
|
+
}
|
|
74
|
+
symbol = symbol_map.get(direction, "→")
|
|
75
|
+
if reversed_edge and direction != RelationshipDirection.BIDIRECTIONAL:
|
|
76
|
+
symbol = "←" if direction == RelationshipDirection.OUTBOUND else "→"
|
|
77
|
+
return symbol
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_observable_tree(
|
|
81
|
+
parent_tree: Tree,
|
|
82
|
+
obs: Any,
|
|
83
|
+
*,
|
|
84
|
+
all_observables: dict[str, Any],
|
|
85
|
+
reverse_relationships: dict[str, list[tuple[Any, Relationship]]],
|
|
86
|
+
visited: set[str],
|
|
87
|
+
rel_info: str = "",
|
|
88
|
+
) -> None:
|
|
89
|
+
if obs.key in visited:
|
|
90
|
+
return
|
|
91
|
+
visited.add(obs.key)
|
|
92
|
+
|
|
93
|
+
color_level = get_color_level(obs.level)
|
|
94
|
+
color_score = get_color_score(obs.score)
|
|
95
|
+
|
|
96
|
+
linked_checks = ""
|
|
97
|
+
if obs.check_links:
|
|
98
|
+
checks_str = "[cyan], [/cyan]".join(escape(check_id) for check_id in obs.check_links)
|
|
99
|
+
linked_checks = f"[cyan][[/cyan]{checks_str}[cyan]][/cyan] "
|
|
100
|
+
|
|
101
|
+
whitelisted_str = " [green]WHITELISTED[/green]" if obs.whitelisted else ""
|
|
102
|
+
|
|
103
|
+
obs_info = (
|
|
104
|
+
f"{rel_info}{linked_checks}[bold]{obs.key}[/bold] "
|
|
105
|
+
f"[{color_score}]{obs.score_display}[/{color_score}] "
|
|
106
|
+
f"[{color_level}]{obs.level.name}[/{color_level}]"
|
|
107
|
+
f"{whitelisted_str}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
child_tree = parent_tree.add(obs_info)
|
|
111
|
+
|
|
112
|
+
# Add outbound children
|
|
113
|
+
for rel in obs.relationships:
|
|
114
|
+
child_obs = all_observables.get(rel.target_key)
|
|
115
|
+
if child_obs:
|
|
116
|
+
direction_symbol = _get_direction_symbol(rel, reversed_edge=False)
|
|
117
|
+
rel_label = f"[dim]{rel.relationship_type_name}[/dim] {direction_symbol} "
|
|
118
|
+
_build_observable_tree(
|
|
119
|
+
child_tree,
|
|
120
|
+
child_obs,
|
|
121
|
+
all_observables=all_observables,
|
|
122
|
+
reverse_relationships=reverse_relationships,
|
|
123
|
+
visited=visited,
|
|
124
|
+
rel_info=rel_label,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Add inbound children (observables pointing to this one)
|
|
128
|
+
for source_obs, rel in reverse_relationships.get(obs.key, []):
|
|
129
|
+
if source_obs.key == obs.key:
|
|
130
|
+
continue
|
|
131
|
+
direction_symbol = _get_direction_symbol(rel, reversed_edge=True)
|
|
132
|
+
rel_label = f"[dim]{rel.relationship_type_name}[/dim] {direction_symbol} "
|
|
133
|
+
_build_observable_tree(
|
|
134
|
+
child_tree,
|
|
135
|
+
source_obs,
|
|
136
|
+
all_observables=all_observables,
|
|
137
|
+
reverse_relationships=reverse_relationships,
|
|
138
|
+
visited=visited,
|
|
139
|
+
rel_info=rel_label,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _render_audit_log_table(
|
|
144
|
+
*,
|
|
145
|
+
rich_print: Callable[[Any], None],
|
|
146
|
+
title: str,
|
|
147
|
+
events: Iterable[Any],
|
|
148
|
+
started_at: datetime | None,
|
|
149
|
+
) -> None:
|
|
150
|
+
def _render_score_change(details: dict[str, Any]) -> str:
|
|
151
|
+
old_score = details.get("old_score")
|
|
152
|
+
new_score = details.get("new_score")
|
|
153
|
+
old_level = details.get("old_level")
|
|
154
|
+
new_level = details.get("new_level")
|
|
155
|
+
|
|
156
|
+
parts: list[str] = []
|
|
157
|
+
if old_score is not None and new_score is not None:
|
|
158
|
+
old_score = old_score if isinstance(old_score, Decimal) else Decimal(str(old_score))
|
|
159
|
+
new_score = new_score if isinstance(new_score, Decimal) else Decimal(str(new_score))
|
|
160
|
+
old_score_color = get_color_score(old_score)
|
|
161
|
+
new_score_color = get_color_score(new_score)
|
|
162
|
+
score_str = (
|
|
163
|
+
f"[{old_score_color}]{_format_score_decimal(old_score)}[/"
|
|
164
|
+
f"{old_score_color}] → "
|
|
165
|
+
f"[{new_score_color}]{_format_score_decimal(new_score)}[/"
|
|
166
|
+
f"{new_score_color}]"
|
|
167
|
+
)
|
|
168
|
+
parts.append(f"Score: {score_str}")
|
|
169
|
+
|
|
170
|
+
if old_level is not None and new_level is not None:
|
|
171
|
+
old_level_enum = normalize_level(old_level)
|
|
172
|
+
new_level_enum = normalize_level(new_level)
|
|
173
|
+
old_level_color = get_color_level(old_level_enum)
|
|
174
|
+
new_level_color = get_color_level(new_level_enum)
|
|
175
|
+
level_str = (
|
|
176
|
+
f"[{old_level_color}]{old_level_enum.name}[/"
|
|
177
|
+
f"{old_level_color}] → "
|
|
178
|
+
f"[{new_level_color}]{new_level_enum.name}[/"
|
|
179
|
+
f"{new_level_color}]"
|
|
180
|
+
)
|
|
181
|
+
parts.append(f"Level: {level_str}")
|
|
182
|
+
|
|
183
|
+
return " | ".join(parts) if parts else "[dim]-[/dim]"
|
|
184
|
+
|
|
185
|
+
def _render_level_change(details: dict[str, Any]) -> str:
|
|
186
|
+
old_level = details.get("old_level")
|
|
187
|
+
new_level = details.get("new_level")
|
|
188
|
+
score = details.get("score")
|
|
189
|
+
if old_level is None or new_level is None:
|
|
190
|
+
return "[dim]-[/dim]"
|
|
191
|
+
old_level_enum = normalize_level(old_level)
|
|
192
|
+
new_level_enum = normalize_level(new_level)
|
|
193
|
+
old_level_color = get_color_level(old_level_enum)
|
|
194
|
+
new_level_color = get_color_level(new_level_enum)
|
|
195
|
+
level_str = (
|
|
196
|
+
f"[{old_level_color}]{old_level_enum.name}[/"
|
|
197
|
+
f"{old_level_color}] → "
|
|
198
|
+
f"[{new_level_color}]{new_level_enum.name}[/"
|
|
199
|
+
f"{new_level_color}]"
|
|
200
|
+
)
|
|
201
|
+
if score is None:
|
|
202
|
+
return f"Level: {level_str}"
|
|
203
|
+
score = score if isinstance(score, Decimal) else Decimal(str(score))
|
|
204
|
+
score_color = get_color_score(score)
|
|
205
|
+
score_str = f"[{score_color}]{_format_score_decimal(score)}[/{score_color}]"
|
|
206
|
+
return f"Level: {level_str} | Score: {score_str}"
|
|
207
|
+
|
|
208
|
+
def _render_merge_event(details: dict[str, Any]) -> str:
|
|
209
|
+
from_name = details.get("from_investigation_name")
|
|
210
|
+
into_name = details.get("into_investigation_name")
|
|
211
|
+
from_id = details.get("from_investigation_id")
|
|
212
|
+
into_id = details.get("into_investigation_id")
|
|
213
|
+
from_label = escape(str(from_name)) if from_name else escape(str(from_id))
|
|
214
|
+
into_label = escape(str(into_name)) if into_name else escape(str(into_id))
|
|
215
|
+
if not from_label or from_label == "None":
|
|
216
|
+
from_label = "[dim]-[/dim]"
|
|
217
|
+
if not into_label or into_label == "None":
|
|
218
|
+
into_label = "[dim]-[/dim]"
|
|
219
|
+
|
|
220
|
+
object_changes = details.get("object_changes") or []
|
|
221
|
+
counts: dict[str, int] = {}
|
|
222
|
+
for change in object_changes:
|
|
223
|
+
action = change.get("action")
|
|
224
|
+
if not action:
|
|
225
|
+
continue
|
|
226
|
+
counts[action] = counts.get(action, 0) + 1
|
|
227
|
+
|
|
228
|
+
if counts:
|
|
229
|
+
parts = [f"{key}={value}" for key, value in sorted(counts.items())]
|
|
230
|
+
summary = ", ".join(parts)
|
|
231
|
+
return f"Merge: {from_label} → {into_label} | Changes: {summary}"
|
|
232
|
+
|
|
233
|
+
return f"Merge: {from_label} → {into_label}"
|
|
234
|
+
|
|
235
|
+
def _render_threat_intel_attached(details: dict[str, Any]) -> str:
|
|
236
|
+
source = details.get("source")
|
|
237
|
+
score = details.get("score")
|
|
238
|
+
level = details.get("level")
|
|
239
|
+
parts: list[str] = []
|
|
240
|
+
if source:
|
|
241
|
+
parts.append(f"Source: [cyan]{escape(str(source))}[/cyan]")
|
|
242
|
+
if level is not None:
|
|
243
|
+
level_enum = normalize_level(level)
|
|
244
|
+
level_color = get_color_level(level_enum)
|
|
245
|
+
parts.append(f"Level: [{level_color}]{level_enum.name}[/{level_color}]")
|
|
246
|
+
if score is not None:
|
|
247
|
+
score_value = score if isinstance(score, Decimal) else Decimal(str(score))
|
|
248
|
+
score_color = get_color_score(score_value)
|
|
249
|
+
score_str = f"[{score_color}]{_format_score_decimal(score_value)}[/{score_color}]"
|
|
250
|
+
parts.append(f"Score: {score_str}")
|
|
251
|
+
return " | ".join(parts) if parts else "[dim]-[/dim]"
|
|
252
|
+
|
|
253
|
+
detail_renderers: dict[str, Callable[[dict[str, Any]], str]] = {
|
|
254
|
+
"SCORE_CHANGED": _render_score_change,
|
|
255
|
+
"SCORE_RECALCULATED": _render_score_change,
|
|
256
|
+
"LEVEL_UPDATED": _render_level_change,
|
|
257
|
+
"INVESTIGATION_MERGED": _render_merge_event,
|
|
258
|
+
"THREAT_INTEL_ATTACHED": _render_threat_intel_attached,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
def _coerce_utc(value: datetime) -> datetime:
|
|
262
|
+
if value.tzinfo is None:
|
|
263
|
+
return value.replace(tzinfo=timezone.utc)
|
|
264
|
+
return value.astimezone(timezone.utc)
|
|
265
|
+
|
|
266
|
+
def _format_elapsed(total_seconds: float) -> str:
|
|
267
|
+
total_ms = int(round(total_seconds * 1000))
|
|
268
|
+
if total_ms < 0:
|
|
269
|
+
total_ms = 0
|
|
270
|
+
hours, rem_ms = divmod(total_ms, 3_600_000)
|
|
271
|
+
minutes, rem_ms = divmod(rem_ms, 60_000)
|
|
272
|
+
seconds, ms = divmod(rem_ms, 1000)
|
|
273
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{ms:03d}"
|
|
274
|
+
|
|
275
|
+
table = Table(title=title, show_lines=False)
|
|
276
|
+
table.add_column("#", justify="right")
|
|
277
|
+
table.add_column("Elapsed", style="dim")
|
|
278
|
+
table.add_column("Event")
|
|
279
|
+
table.add_column("Object")
|
|
280
|
+
table.add_column("Context")
|
|
281
|
+
|
|
282
|
+
events_sorted = sorted(events, key=lambda evt: evt.timestamp)
|
|
283
|
+
effective_start = _coerce_utc(started_at) if started_at is not None else None
|
|
284
|
+
if effective_start is None and events_sorted:
|
|
285
|
+
effective_start = _coerce_utc(events_sorted[0].timestamp)
|
|
286
|
+
|
|
287
|
+
grouped_events: dict[str, list[Any]] = {}
|
|
288
|
+
group_order: list[str] = []
|
|
289
|
+
for event in events_sorted:
|
|
290
|
+
group_key = event.object_key or ""
|
|
291
|
+
if group_key not in grouped_events:
|
|
292
|
+
grouped_events[group_key] = []
|
|
293
|
+
group_order.append(group_key)
|
|
294
|
+
grouped_events[group_key].append(event)
|
|
295
|
+
|
|
296
|
+
row_idx = 1
|
|
297
|
+
for group_key in group_order:
|
|
298
|
+
if row_idx > 1:
|
|
299
|
+
table.add_section()
|
|
300
|
+
for event in grouped_events[group_key]:
|
|
301
|
+
event_timestamp = _coerce_utc(event.timestamp)
|
|
302
|
+
elapsed = ""
|
|
303
|
+
if effective_start is not None:
|
|
304
|
+
elapsed = _format_elapsed((event_timestamp - effective_start).total_seconds())
|
|
305
|
+
|
|
306
|
+
event_type = escape(event.event_type)
|
|
307
|
+
object_label = "[dim]-[/dim]"
|
|
308
|
+
if event.object_key:
|
|
309
|
+
object_label = escape(event.object_key)
|
|
310
|
+
reason = escape(event.reason) if event.reason else "[dim]-[/dim]"
|
|
311
|
+
details = "[dim]-[/dim]"
|
|
312
|
+
renderer = detail_renderers.get(event.event_type)
|
|
313
|
+
if renderer:
|
|
314
|
+
details = renderer(getattr(event, "details", {}) or {})
|
|
315
|
+
|
|
316
|
+
if reason == "[dim]-[/dim]":
|
|
317
|
+
context = details
|
|
318
|
+
elif details == "[dim]-[/dim]":
|
|
319
|
+
context = reason
|
|
320
|
+
else:
|
|
321
|
+
context = details
|
|
322
|
+
|
|
323
|
+
table.add_row(
|
|
324
|
+
str(row_idx),
|
|
325
|
+
elapsed,
|
|
326
|
+
event_type,
|
|
327
|
+
object_label,
|
|
328
|
+
context,
|
|
329
|
+
)
|
|
330
|
+
row_idx += 1
|
|
331
|
+
|
|
332
|
+
table.caption = "No audit events recorded." if not events_sorted else ""
|
|
333
|
+
rich_print(table)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def display_summary(
|
|
337
|
+
cv: Cyvest,
|
|
338
|
+
rich_print: Callable[[Any], None],
|
|
339
|
+
show_graph: bool = True,
|
|
340
|
+
exclude_levels: Level | Iterable[Level] = Level.NONE,
|
|
341
|
+
show_audit_log: bool = False,
|
|
342
|
+
) -> None:
|
|
343
|
+
"""
|
|
344
|
+
Display a comprehensive summary of the investigation using Rich.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
cv: Cyvest investigation to display
|
|
348
|
+
rich_print: A rich renderable handler that is called with renderables for output
|
|
349
|
+
show_graph: Whether to display the observable graph
|
|
350
|
+
exclude_levels: Level(s) to omit from the report (default: Level.NONE)
|
|
351
|
+
show_audit_log: Whether to display the investigation audit log (default: False)
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
resolved_excluded_levels = _normalize_exclude_levels(exclude_levels)
|
|
355
|
+
|
|
356
|
+
all_checks = cv.check_get_all().values()
|
|
357
|
+
filtered_checks = [c for c in all_checks if c.level not in resolved_excluded_levels]
|
|
358
|
+
applied_checks = sum(1 for c in filtered_checks if c.level != Level.NONE)
|
|
359
|
+
|
|
360
|
+
excluded_caption = ""
|
|
361
|
+
if resolved_excluded_levels:
|
|
362
|
+
excluded_names = ", ".join(level.name for level in sorted(resolved_excluded_levels, key=lambda lvl: lvl.value))
|
|
363
|
+
excluded_caption = f" (excluding: {excluded_names})"
|
|
364
|
+
|
|
365
|
+
caption_parts = [
|
|
366
|
+
f"Total Checks: {len(cv.check_get_all())}",
|
|
367
|
+
f"Displayed: {len(filtered_checks)}{excluded_caption}",
|
|
368
|
+
f"Applied: {applied_checks}",
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
table = Table(
|
|
372
|
+
title="Investigation Report",
|
|
373
|
+
caption=" | ".join(caption_parts),
|
|
374
|
+
)
|
|
375
|
+
table.add_column("Name")
|
|
376
|
+
table.add_column("Score", justify="right")
|
|
377
|
+
table.add_column("Level", justify="center")
|
|
378
|
+
|
|
379
|
+
# Checks by level section
|
|
380
|
+
rule = Rule(f"[bold magenta]CHECKS[/bold magenta]: {len(cv.check_get_all())} checks")
|
|
381
|
+
table.add_row(rule, "-", "-")
|
|
382
|
+
|
|
383
|
+
for level_enum in sorted(Level, reverse=True):
|
|
384
|
+
if level_enum in resolved_excluded_levels:
|
|
385
|
+
continue
|
|
386
|
+
checks = [c for c in cv.check_get_all().values() if c.level == level_enum]
|
|
387
|
+
checks = sorted(checks, key=_sort_key_by_score)
|
|
388
|
+
if checks:
|
|
389
|
+
color_level = get_color_level(level_enum)
|
|
390
|
+
level_rule = Align(
|
|
391
|
+
f"[bold {color_level}]{level_enum.name}: {len(checks)} check(s)[/bold {color_level}]",
|
|
392
|
+
align="center",
|
|
393
|
+
)
|
|
394
|
+
table.add_row(level_rule, "-", "-")
|
|
395
|
+
|
|
396
|
+
for check in checks:
|
|
397
|
+
color_score = get_color_score(check.score)
|
|
398
|
+
name = f" {check.check_name}"
|
|
399
|
+
score = f"[{color_score}]{check.score_display}[/{color_score}]"
|
|
400
|
+
level = f"[{color_level}]{check.level.name}[/{color_level}]"
|
|
401
|
+
table.add_row(name, score, level)
|
|
402
|
+
|
|
403
|
+
# Tags section (if any)
|
|
404
|
+
all_tags = cv.tag_get_all()
|
|
405
|
+
if all_tags:
|
|
406
|
+
table.add_section()
|
|
407
|
+
rule = Rule(f"[bold magenta]TAGS[/bold magenta]: {len(all_tags)} tags")
|
|
408
|
+
table.add_row(rule, "-", "-")
|
|
409
|
+
|
|
410
|
+
for tag in sorted(all_tags.values(), key=lambda t: t.name):
|
|
411
|
+
agg_score = tag.get_aggregated_score()
|
|
412
|
+
agg_level = tag.get_aggregated_level()
|
|
413
|
+
color_level = get_color_level(agg_level)
|
|
414
|
+
color_score = get_color_score(agg_score)
|
|
415
|
+
|
|
416
|
+
name = f" {tag.name}"
|
|
417
|
+
score = f"[{color_score}]{agg_score:.2f}[/{color_score}]"
|
|
418
|
+
level = f"[{color_level}]{agg_level.name}[/{color_level}]"
|
|
419
|
+
table.add_row(name, score, level)
|
|
420
|
+
|
|
421
|
+
# Enrichments section (if any)
|
|
422
|
+
if cv.enrichment_get_all():
|
|
423
|
+
table.add_section()
|
|
424
|
+
rule = Rule(f"[bold magenta]ENRICHMENTS[/bold magenta]: {len(cv.enrichment_get_all())} enrichments")
|
|
425
|
+
table.add_row(rule, "-", "-")
|
|
426
|
+
|
|
427
|
+
for enr in cv.enrichment_get_all().values():
|
|
428
|
+
table.add_row(f" {enr.name}", "-", "-")
|
|
429
|
+
|
|
430
|
+
# Statistics section
|
|
431
|
+
table.add_section()
|
|
432
|
+
rule = Rule("[bold magenta]STATISTICS[/bold magenta]")
|
|
433
|
+
table.add_row(rule, "-", "-")
|
|
434
|
+
|
|
435
|
+
stats = cv.get_statistics()
|
|
436
|
+
stat_items = [
|
|
437
|
+
("Total Observables", stats.total_observables),
|
|
438
|
+
("Internal Observables", stats.internal_observables),
|
|
439
|
+
("External Observables", stats.external_observables),
|
|
440
|
+
("Whitelisted Observables", stats.whitelisted_observables),
|
|
441
|
+
("Total Threat Intel", stats.total_threat_intel),
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
for stat_name, stat_value in stat_items:
|
|
445
|
+
table.add_row(f" {stat_name}", str(stat_value), "-")
|
|
446
|
+
|
|
447
|
+
# Global score footer
|
|
448
|
+
global_score = cv.get_global_score()
|
|
449
|
+
global_level = cv.get_global_level()
|
|
450
|
+
color_level = get_color_level(global_level)
|
|
451
|
+
color_score = get_color_score(global_score)
|
|
452
|
+
|
|
453
|
+
table.add_section()
|
|
454
|
+
table.add_row(
|
|
455
|
+
Align("[bold]GLOBAL SCORE[/bold]", align="center"),
|
|
456
|
+
f"[{color_score}]{global_score:.2f}[/{color_score}]",
|
|
457
|
+
f"[{color_level}]{global_level.name}[/{color_level}]",
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Print table
|
|
461
|
+
rich_print(table)
|
|
462
|
+
|
|
463
|
+
# Observable graph (if requested)
|
|
464
|
+
if show_graph and cv.observable_get_all():
|
|
465
|
+
tree = Tree("Observables", hide_root=True)
|
|
466
|
+
|
|
467
|
+
# Precompute reverse relationships to traverse observables that only
|
|
468
|
+
# appear as targets (e.g., child → parent links).
|
|
469
|
+
all_observables = cv.observable_get_all()
|
|
470
|
+
reverse_relationships: dict[str, list[tuple[Observable, Relationship]]] = {}
|
|
471
|
+
for source_obs in all_observables.values():
|
|
472
|
+
for rel in source_obs.relationships:
|
|
473
|
+
reverse_relationships.setdefault(rel.target_key, []).append((source_obs, rel))
|
|
474
|
+
|
|
475
|
+
# Start from root
|
|
476
|
+
root = cv.observable_get_root()
|
|
477
|
+
if root:
|
|
478
|
+
_build_observable_tree(
|
|
479
|
+
tree,
|
|
480
|
+
root,
|
|
481
|
+
all_observables=all_observables,
|
|
482
|
+
reverse_relationships=reverse_relationships,
|
|
483
|
+
visited=set(),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
rich_print(tree)
|
|
487
|
+
|
|
488
|
+
if show_audit_log:
|
|
489
|
+
investigation = getattr(cv, "_investigation", None)
|
|
490
|
+
events = investigation.get_audit_log() if investigation else []
|
|
491
|
+
if events:
|
|
492
|
+
started_at = investigation.started_at if investigation else None
|
|
493
|
+
_render_audit_log_table(
|
|
494
|
+
rich_print=rich_print,
|
|
495
|
+
title="Audit Log",
|
|
496
|
+
events=events,
|
|
497
|
+
started_at=started_at,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def display_statistics(cv: Cyvest, rich_print: Callable[[Any], None]) -> None:
|
|
502
|
+
"""
|
|
503
|
+
Display detailed statistics about the investigation.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
cv: Cyvest investigation
|
|
507
|
+
rich_print: A rich renderable handler that is called with renderables for output
|
|
508
|
+
"""
|
|
509
|
+
stats = cv.get_statistics()
|
|
510
|
+
|
|
511
|
+
# Observable statistics table
|
|
512
|
+
obs_table = Table(title="Observable Statistics")
|
|
513
|
+
obs_table.add_column("Type", style="cyan")
|
|
514
|
+
obs_table.add_column("Total", justify="right")
|
|
515
|
+
obs_table.add_column("INFO", justify="right", style="cyan")
|
|
516
|
+
obs_table.add_column("NOTABLE", justify="right", style="yellow")
|
|
517
|
+
obs_table.add_column("SUSPICIOUS", justify="right", style="orange3")
|
|
518
|
+
obs_table.add_column("MALICIOUS", justify="right", style="red")
|
|
519
|
+
|
|
520
|
+
obs_by_type_level = stats.observables_by_type_and_level
|
|
521
|
+
for obs_type, count in stats.observables_by_type.items():
|
|
522
|
+
levels = obs_by_type_level.get(obs_type, {})
|
|
523
|
+
obs_table.add_row(
|
|
524
|
+
obs_type.upper(),
|
|
525
|
+
str(count),
|
|
526
|
+
str(levels.get("INFO", 0)),
|
|
527
|
+
str(levels.get("NOTABLE", 0)),
|
|
528
|
+
str(levels.get("SUSPICIOUS", 0)),
|
|
529
|
+
str(levels.get("MALICIOUS", 0)),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
rich_print(obs_table)
|
|
533
|
+
|
|
534
|
+
# Check statistics table
|
|
535
|
+
rich_print("")
|
|
536
|
+
check_table = Table(title="Check Statistics")
|
|
537
|
+
check_table.add_column("Level", style="cyan")
|
|
538
|
+
check_table.add_column("Count", justify="right")
|
|
539
|
+
|
|
540
|
+
for level, count in stats.checks_by_level.items():
|
|
541
|
+
check_table.add_row(level, str(count))
|
|
542
|
+
|
|
543
|
+
rich_print(check_table)
|
|
544
|
+
|
|
545
|
+
# Threat intel statistics
|
|
546
|
+
if stats.total_threat_intel > 0:
|
|
547
|
+
rich_print("")
|
|
548
|
+
ti_table = Table(title="Threat Intelligence Statistics")
|
|
549
|
+
ti_table.add_column("Source", style="cyan")
|
|
550
|
+
ti_table.add_column("Count", justify="right")
|
|
551
|
+
|
|
552
|
+
for source, count in stats.threat_intel_by_source.items():
|
|
553
|
+
ti_table.add_row(source, str(count))
|
|
554
|
+
|
|
555
|
+
rich_print(ti_table)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _format_level_score(
|
|
559
|
+
level: Level | None,
|
|
560
|
+
score: Decimal | None,
|
|
561
|
+
score_rule: str | None = None,
|
|
562
|
+
) -> str:
|
|
563
|
+
"""Format level and score for display."""
|
|
564
|
+
if level is None and score is None and not score_rule:
|
|
565
|
+
return "[dim]-[/dim]"
|
|
566
|
+
|
|
567
|
+
parts: list[str] = []
|
|
568
|
+
if level:
|
|
569
|
+
color = get_color_level(level)
|
|
570
|
+
parts.append(f"[{color}]{level.name}[/{color}]")
|
|
571
|
+
|
|
572
|
+
if score_rule:
|
|
573
|
+
parts.append(score_rule)
|
|
574
|
+
elif score is not None:
|
|
575
|
+
color = get_color_score(score)
|
|
576
|
+
parts.append(f"[{color}]{_format_score_decimal(score)}[/{color}]")
|
|
577
|
+
|
|
578
|
+
return " ".join(parts) if parts else "[dim]-[/dim]"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def display_diff(
|
|
582
|
+
diffs: list,
|
|
583
|
+
rich_print: Callable[[Any], None],
|
|
584
|
+
title: str = "Diff",
|
|
585
|
+
) -> None:
|
|
586
|
+
"""
|
|
587
|
+
Display investigation diff in a rich table with tree structure.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
diffs: List of DiffItem objects representing differences
|
|
591
|
+
rich_print: A rich renderable handler called with renderables for output
|
|
592
|
+
title: Title for the diff table
|
|
593
|
+
"""
|
|
594
|
+
# Import here to avoid circular dependency
|
|
595
|
+
from cyvest.compare import DiffStatus
|
|
596
|
+
|
|
597
|
+
# Count diffs by status
|
|
598
|
+
added = sum(1 for d in diffs if d.status == DiffStatus.ADDED)
|
|
599
|
+
removed = sum(1 for d in diffs if d.status == DiffStatus.REMOVED)
|
|
600
|
+
mismatch = sum(1 for d in diffs if d.status == DiffStatus.MISMATCH)
|
|
601
|
+
|
|
602
|
+
# Build caption with combined legend and counts
|
|
603
|
+
caption = (
|
|
604
|
+
f"[green]+ {added}[/green] added | [red]- {removed}[/red] removed | [yellow]\u2717 {mismatch}[/yellow] mismatch"
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
table = Table(title=title, caption=caption)
|
|
608
|
+
table.add_column("Key")
|
|
609
|
+
table.add_column("Expected", justify="center")
|
|
610
|
+
table.add_column("Actual", justify="center")
|
|
611
|
+
table.add_column("Status", justify="center", width=8)
|
|
612
|
+
|
|
613
|
+
status_styles = {
|
|
614
|
+
DiffStatus.ADDED: "green",
|
|
615
|
+
DiffStatus.REMOVED: "red",
|
|
616
|
+
DiffStatus.MISMATCH: "yellow",
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
for idx, diff in enumerate(diffs):
|
|
620
|
+
# Add section separator between checks (except before first)
|
|
621
|
+
if idx > 0:
|
|
622
|
+
table.add_section()
|
|
623
|
+
|
|
624
|
+
status_style = status_styles.get(diff.status, "white")
|
|
625
|
+
|
|
626
|
+
# Check row (main row)
|
|
627
|
+
expected_str = _format_level_score(diff.expected_level, diff.expected_score, diff.expected_score_rule)
|
|
628
|
+
actual_str = _format_level_score(diff.actual_level, diff.actual_score)
|
|
629
|
+
|
|
630
|
+
table.add_row(
|
|
631
|
+
escape(diff.key),
|
|
632
|
+
expected_str,
|
|
633
|
+
actual_str,
|
|
634
|
+
f"[{status_style}]{diff.status.value}[/{status_style}]",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# Observable rows (indented with └──)
|
|
638
|
+
for obs_idx, obs in enumerate(diff.observable_diffs):
|
|
639
|
+
is_last_obs = obs_idx == len(diff.observable_diffs) - 1
|
|
640
|
+
obs_prefix = "└──" if is_last_obs else "├──"
|
|
641
|
+
|
|
642
|
+
obs_label = obs.observable_key
|
|
643
|
+
obs_expected = _format_level_score(obs.expected_level, obs.expected_score)
|
|
644
|
+
obs_actual = _format_level_score(obs.actual_level, obs.actual_score)
|
|
645
|
+
|
|
646
|
+
table.add_row(
|
|
647
|
+
f"{obs_prefix} [cyan]{escape(obs_label)}[/cyan]",
|
|
648
|
+
obs_expected,
|
|
649
|
+
obs_actual,
|
|
650
|
+
"",
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# Threat intel rows (indented further with │ └── or └──)
|
|
654
|
+
for ti_idx, ti in enumerate(obs.threat_intel_diffs):
|
|
655
|
+
is_last_ti = ti_idx == len(obs.threat_intel_diffs) - 1
|
|
656
|
+
# Use │ continuation if not last observable, else spaces
|
|
657
|
+
continuation = "│ " if not is_last_obs else " "
|
|
658
|
+
ti_prefix = "└──" if is_last_ti else "├──"
|
|
659
|
+
|
|
660
|
+
ti_expected = _format_level_score(ti.expected_level, ti.expected_score)
|
|
661
|
+
ti_actual = _format_level_score(ti.actual_level, ti.actual_score)
|
|
662
|
+
|
|
663
|
+
table.add_row(
|
|
664
|
+
f"{continuation}{ti_prefix} [magenta]{escape(ti.source)}[/magenta]",
|
|
665
|
+
ti_expected,
|
|
666
|
+
ti_actual,
|
|
667
|
+
"",
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
rich_print(table)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _format_extra_data(extra: dict[str, Any]) -> str:
|
|
674
|
+
"""Format extra data as a compact JSON string."""
|
|
675
|
+
if not extra:
|
|
676
|
+
return "[dim]-[/dim]"
|
|
677
|
+
try:
|
|
678
|
+
return escape(json.dumps(extra, indent=2, default=str))
|
|
679
|
+
except (TypeError, ValueError):
|
|
680
|
+
return escape(str(extra))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _build_ti_tree_for_observable(
|
|
684
|
+
ti_list: list,
|
|
685
|
+
parent_tree: Tree,
|
|
686
|
+
) -> None:
|
|
687
|
+
"""Build a tree of threat intel entries for an observable."""
|
|
688
|
+
for ti in ti_list:
|
|
689
|
+
color_level = get_color_level(ti.level)
|
|
690
|
+
color_score = get_color_score(ti.score)
|
|
691
|
+
ti_label = (
|
|
692
|
+
f"[magenta]{escape(ti.source)}[/magenta] "
|
|
693
|
+
f"[{color_score}]{ti.score_display}[/{color_score}] "
|
|
694
|
+
f"[bold {color_level}]{ti.level.name}[/bold {color_level}]"
|
|
695
|
+
)
|
|
696
|
+
ti_node = parent_tree.add(ti_label)
|
|
697
|
+
|
|
698
|
+
# Add taxonomies as children
|
|
699
|
+
if ti.taxonomies:
|
|
700
|
+
for tax in ti.taxonomies:
|
|
701
|
+
tax_color = get_color_level(tax.level)
|
|
702
|
+
tax_label = f"[{tax_color}]{tax.level.name}[/{tax_color}] {escape(tax.name)}: {escape(tax.value)}"
|
|
703
|
+
ti_node.add(tax_label)
|
|
704
|
+
|
|
705
|
+
# Add comment if present
|
|
706
|
+
if ti.comment:
|
|
707
|
+
ti_node.add(f"[dim]Comment:[/dim] {escape(ti.comment)}")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _build_relationship_tree_depth(
|
|
711
|
+
obs_key: str,
|
|
712
|
+
all_observables: dict[str, Any],
|
|
713
|
+
all_threat_intels: dict[str, Any],
|
|
714
|
+
max_depth: int,
|
|
715
|
+
) -> Tree:
|
|
716
|
+
"""Build a tree showing relationships up to max_depth with scores and levels."""
|
|
717
|
+
tree = Tree(f"[bold]Relationships[/bold] (depth={max_depth})")
|
|
718
|
+
|
|
719
|
+
if max_depth < 1:
|
|
720
|
+
tree.add("[dim]No relationships (depth=0)[/dim]")
|
|
721
|
+
return tree
|
|
722
|
+
|
|
723
|
+
obs = all_observables.get(obs_key)
|
|
724
|
+
if not obs:
|
|
725
|
+
return tree
|
|
726
|
+
|
|
727
|
+
# Build reverse relationship map
|
|
728
|
+
reverse_relationships: dict[str, list[tuple[Any, Relationship]]] = {}
|
|
729
|
+
for source_obs in all_observables.values():
|
|
730
|
+
for rel in source_obs.relationships:
|
|
731
|
+
reverse_relationships.setdefault(rel.target_key, []).append((source_obs, rel))
|
|
732
|
+
|
|
733
|
+
visited: set[str] = {obs_key}
|
|
734
|
+
|
|
735
|
+
def _add_relationships(current_obs: Any, parent_tree: Tree, depth: int) -> None:
|
|
736
|
+
if depth > max_depth:
|
|
737
|
+
return
|
|
738
|
+
|
|
739
|
+
# Outbound relationships
|
|
740
|
+
for rel in current_obs.relationships:
|
|
741
|
+
target_obs = all_observables.get(rel.target_key)
|
|
742
|
+
if not target_obs or target_obs.key in visited:
|
|
743
|
+
continue
|
|
744
|
+
|
|
745
|
+
visited.add(target_obs.key)
|
|
746
|
+
direction_symbol = _get_direction_symbol(rel, reversed_edge=False)
|
|
747
|
+
color_level = get_color_level(target_obs.level)
|
|
748
|
+
color_score = get_color_score(target_obs.score)
|
|
749
|
+
|
|
750
|
+
rel_label = (
|
|
751
|
+
f"{direction_symbol} [dim]{rel.relationship_type_name}[/dim] "
|
|
752
|
+
f"[bold]{escape(target_obs.key)}[/bold] "
|
|
753
|
+
f"[{color_score}]{target_obs.score_display}[/{color_score}] "
|
|
754
|
+
f"[bold {color_level}]{target_obs.level.name}[/bold {color_level}]"
|
|
755
|
+
)
|
|
756
|
+
child_node = parent_tree.add(rel_label)
|
|
757
|
+
|
|
758
|
+
if depth < max_depth:
|
|
759
|
+
_add_relationships(target_obs, child_node, depth + 1)
|
|
760
|
+
|
|
761
|
+
# Inbound relationships
|
|
762
|
+
for source_obs, rel in reverse_relationships.get(current_obs.key, []):
|
|
763
|
+
if source_obs.key == current_obs.key or source_obs.key in visited:
|
|
764
|
+
continue
|
|
765
|
+
|
|
766
|
+
visited.add(source_obs.key)
|
|
767
|
+
direction_symbol = _get_direction_symbol(rel, reversed_edge=True)
|
|
768
|
+
color_level = get_color_level(source_obs.level)
|
|
769
|
+
color_score = get_color_score(source_obs.score)
|
|
770
|
+
|
|
771
|
+
rel_label = (
|
|
772
|
+
f"{direction_symbol} [dim]{rel.relationship_type_name}[/dim] "
|
|
773
|
+
f"[bold]{escape(source_obs.key)}[/bold] "
|
|
774
|
+
f"[{color_score}]{source_obs.score_display}[/{color_score}] "
|
|
775
|
+
f"[bold {color_level}]{source_obs.level.name}[/bold {color_level}]"
|
|
776
|
+
)
|
|
777
|
+
child_node = parent_tree.add(rel_label)
|
|
778
|
+
|
|
779
|
+
if depth < max_depth:
|
|
780
|
+
_add_relationships(source_obs, child_node, depth + 1)
|
|
781
|
+
|
|
782
|
+
_add_relationships(obs, tree, 1)
|
|
783
|
+
|
|
784
|
+
return tree
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def display_check_query(
|
|
788
|
+
cv: Cyvest,
|
|
789
|
+
check_key: str,
|
|
790
|
+
rich_print: Callable[[Any], None],
|
|
791
|
+
) -> None:
|
|
792
|
+
"""
|
|
793
|
+
Display detailed information about a check.
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
cv: Cyvest investigation
|
|
797
|
+
check_key: Key of the check to display
|
|
798
|
+
rich_print: Rich renderable handler
|
|
799
|
+
|
|
800
|
+
Raises:
|
|
801
|
+
KeyError: If check not found
|
|
802
|
+
"""
|
|
803
|
+
check = cv.check_get(check_key)
|
|
804
|
+
if check is None:
|
|
805
|
+
raise KeyError(f"Check '{check_key}' not found in investigation.")
|
|
806
|
+
|
|
807
|
+
color_level = get_color_level(check.level)
|
|
808
|
+
color_score = get_color_score(check.score)
|
|
809
|
+
|
|
810
|
+
# Build info table
|
|
811
|
+
table = Table(show_header=False, box=None)
|
|
812
|
+
table.add_column("Field", style="cyan")
|
|
813
|
+
table.add_column("Value")
|
|
814
|
+
|
|
815
|
+
table.add_row("Key", f"[bold]{escape(check.key)}[/bold]")
|
|
816
|
+
table.add_row("Name", escape(check.check_name))
|
|
817
|
+
table.add_row("Description", escape(check.description) if check.description else "[dim]-[/dim]")
|
|
818
|
+
table.add_row(
|
|
819
|
+
"Score",
|
|
820
|
+
f"[bold {color_score}]{check.score_display}[/bold {color_score}]",
|
|
821
|
+
)
|
|
822
|
+
table.add_row(
|
|
823
|
+
"Level",
|
|
824
|
+
f"[bold {color_level}]{check.level.name}[/bold {color_level}]",
|
|
825
|
+
)
|
|
826
|
+
table.add_row("Comment", escape(check.comment) if check.comment else "[dim]-[/dim]")
|
|
827
|
+
table.add_row(
|
|
828
|
+
"Origin Investigation",
|
|
829
|
+
escape(check.origin_investigation_id) if check.origin_investigation_id else "[dim]-[/dim]",
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
# Extra data
|
|
833
|
+
if check.extra:
|
|
834
|
+
table.add_row("Extra", _format_extra_data(check.extra))
|
|
835
|
+
|
|
836
|
+
rich_print(
|
|
837
|
+
Panel(
|
|
838
|
+
table,
|
|
839
|
+
title=f"[bold]Check:[/bold] {escape(check.check_name)}",
|
|
840
|
+
border_style="blue",
|
|
841
|
+
expand=False,
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Linked observables tree
|
|
846
|
+
observable_links = check.observable_links
|
|
847
|
+
if observable_links:
|
|
848
|
+
all_observables = cv.observable_get_all()
|
|
849
|
+
|
|
850
|
+
tree = Tree("[bold]Linked Observables[/bold]")
|
|
851
|
+
|
|
852
|
+
for link in observable_links:
|
|
853
|
+
obs = all_observables.get(link.observable_key)
|
|
854
|
+
if not obs:
|
|
855
|
+
tree.add(f"[dim]{escape(link.observable_key)} (not found)[/dim]")
|
|
856
|
+
continue
|
|
857
|
+
|
|
858
|
+
obs_color_level = get_color_level(obs.level)
|
|
859
|
+
obs_color_score = get_color_score(obs.score)
|
|
860
|
+
whitelisted_str = " [green]WHITELISTED[/green]" if obs.whitelisted else ""
|
|
861
|
+
prop_mode = f" [dim]({link.propagation_mode.value})[/dim]" if hasattr(link, "propagation_mode") else ""
|
|
862
|
+
|
|
863
|
+
obs_label = (
|
|
864
|
+
f"[bold]{escape(obs.key)}[/bold] "
|
|
865
|
+
f"[{obs_color_score}]{obs.score_display}[/{obs_color_score}] "
|
|
866
|
+
f"[bold {obs_color_level}]{obs.level.name}[/bold {obs_color_level}]"
|
|
867
|
+
f"{whitelisted_str}{prop_mode}"
|
|
868
|
+
)
|
|
869
|
+
obs_node = tree.add(obs_label)
|
|
870
|
+
|
|
871
|
+
# Add threat intel for this observable
|
|
872
|
+
for ti in obs.threat_intels:
|
|
873
|
+
ti_color_level = get_color_level(ti.level)
|
|
874
|
+
ti_color_score = get_color_score(ti.score)
|
|
875
|
+
ti_label = (
|
|
876
|
+
f"[magenta]{escape(ti.source)}[/magenta] "
|
|
877
|
+
f"[{ti_color_score}]{ti.score_display}[/{ti_color_score}] "
|
|
878
|
+
f"[bold {ti_color_level}]{ti.level.name}[/bold {ti_color_level}]"
|
|
879
|
+
)
|
|
880
|
+
obs_node.add(ti_label)
|
|
881
|
+
|
|
882
|
+
rich_print(Panel(tree, border_style="green", expand=False))
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def display_observable_query(
|
|
886
|
+
cv: Cyvest,
|
|
887
|
+
observable_key: str,
|
|
888
|
+
rich_print: Callable[[Any], None],
|
|
889
|
+
*,
|
|
890
|
+
depth: int = 1,
|
|
891
|
+
) -> None:
|
|
892
|
+
"""
|
|
893
|
+
Display detailed information about an observable.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
cv: Cyvest investigation
|
|
897
|
+
observable_key: Key of the observable to display
|
|
898
|
+
rich_print: Rich renderable handler
|
|
899
|
+
depth: Relationship traversal depth (default 1)
|
|
900
|
+
|
|
901
|
+
Raises:
|
|
902
|
+
KeyError: If observable not found
|
|
903
|
+
"""
|
|
904
|
+
obs = cv.observable_get(observable_key)
|
|
905
|
+
if obs is None:
|
|
906
|
+
raise KeyError(f"Observable '{observable_key}' not found in investigation.")
|
|
907
|
+
|
|
908
|
+
color_level = get_color_level(obs.level)
|
|
909
|
+
color_score = get_color_score(obs.score)
|
|
910
|
+
|
|
911
|
+
# Build info table
|
|
912
|
+
obs_type_str = obs.obs_type.value if hasattr(obs.obs_type, "value") else str(obs.obs_type)
|
|
913
|
+
table = Table(show_header=False, box=None)
|
|
914
|
+
table.add_column("Field", style="cyan")
|
|
915
|
+
table.add_column("Value")
|
|
916
|
+
|
|
917
|
+
table.add_row("Key", f"[bold]{escape(obs.key)}[/bold]")
|
|
918
|
+
table.add_row("Type", escape(obs_type_str))
|
|
919
|
+
table.add_row("Value", escape(obs.value))
|
|
920
|
+
table.add_row(
|
|
921
|
+
"Score",
|
|
922
|
+
f"[bold {color_score}]{obs.score_display}[/bold {color_score}]",
|
|
923
|
+
)
|
|
924
|
+
table.add_row(
|
|
925
|
+
"Level",
|
|
926
|
+
f"[bold {color_level}]{obs.level.name}[/bold {color_level}]",
|
|
927
|
+
)
|
|
928
|
+
table.add_row("Internal", "[green]Yes[/green]" if obs.internal else "[yellow]No[/yellow]")
|
|
929
|
+
table.add_row("Whitelisted", "[green]Yes[/green]" if obs.whitelisted else "[dim]No[/dim]")
|
|
930
|
+
table.add_row("Comment", escape(obs.comment) if obs.comment else "[dim]-[/dim]")
|
|
931
|
+
|
|
932
|
+
# Check links
|
|
933
|
+
if obs.check_links:
|
|
934
|
+
checks_str = ", ".join(escape(ck) for ck in obs.check_links)
|
|
935
|
+
table.add_row("Linked Checks", f"[cyan]{checks_str}[/cyan]")
|
|
936
|
+
|
|
937
|
+
# Extra data
|
|
938
|
+
if obs.extra:
|
|
939
|
+
table.add_row("Extra", _format_extra_data(obs.extra))
|
|
940
|
+
|
|
941
|
+
rich_print(
|
|
942
|
+
Panel(
|
|
943
|
+
table,
|
|
944
|
+
title=f"[bold]Observable:[/bold] {escape(obs_type_str)}",
|
|
945
|
+
border_style="green",
|
|
946
|
+
expand=False,
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
# Build score breakdown, threat intel, and relationships panel
|
|
951
|
+
all_observables = cv.observable_get_all()
|
|
952
|
+
renderables = []
|
|
953
|
+
|
|
954
|
+
# Get score mode from investigation
|
|
955
|
+
score_mode = "MAX"
|
|
956
|
+
try:
|
|
957
|
+
score_mode = cv._investigation._score_engine._score_mode_obs.value.upper()
|
|
958
|
+
except AttributeError:
|
|
959
|
+
pass
|
|
960
|
+
|
|
961
|
+
# Score breakdown table
|
|
962
|
+
ti_scores: list[Decimal] = []
|
|
963
|
+
child_scores: list[Decimal] = []
|
|
964
|
+
|
|
965
|
+
if obs.threat_intels or obs.relationships:
|
|
966
|
+
score_table = Table(title=f"[bold]Score Breakdown[/bold] (mode: {score_mode})")
|
|
967
|
+
score_table.add_column("Source", style="cyan")
|
|
968
|
+
score_table.add_column("Score", justify="right")
|
|
969
|
+
score_table.add_column("Level", justify="center")
|
|
970
|
+
score_table.add_column("Type", style="dim")
|
|
971
|
+
|
|
972
|
+
# Add threat intel contributions
|
|
973
|
+
for ti in obs.threat_intels:
|
|
974
|
+
ti_color_score = get_color_score(ti.score)
|
|
975
|
+
ti_color_level = get_color_level(ti.level)
|
|
976
|
+
score_table.add_row(
|
|
977
|
+
escape(ti.key),
|
|
978
|
+
f"[{ti_color_score}]{ti.score_display}[/{ti_color_score}]",
|
|
979
|
+
f"[{ti_color_level}]{ti.level.name}[/{ti_color_level}]",
|
|
980
|
+
"threat_intel",
|
|
981
|
+
)
|
|
982
|
+
ti_scores.append(ti.score)
|
|
983
|
+
|
|
984
|
+
# Add child observable contributions (OUTBOUND relationships)
|
|
985
|
+
for rel in obs.relationships:
|
|
986
|
+
if rel.direction == RelationshipDirection.OUTBOUND:
|
|
987
|
+
child = all_observables.get(rel.target_key)
|
|
988
|
+
if child and child.value != "root":
|
|
989
|
+
child_color_score = get_color_score(child.score)
|
|
990
|
+
child_color_level = get_color_level(child.level)
|
|
991
|
+
score_table.add_row(
|
|
992
|
+
escape(child.key),
|
|
993
|
+
f"[{child_color_score}]{child.score_display}[/{child_color_score}]",
|
|
994
|
+
f"[{child_color_level}]{child.level.name}[/{child_color_level}]",
|
|
995
|
+
"child",
|
|
996
|
+
)
|
|
997
|
+
child_scores.append(child.score)
|
|
998
|
+
|
|
999
|
+
# Add computed total row
|
|
1000
|
+
if ti_scores or child_scores:
|
|
1001
|
+
score_table.add_section()
|
|
1002
|
+
if score_mode == "MAX":
|
|
1003
|
+
computed = max(ti_scores + child_scores, default=Decimal("0"))
|
|
1004
|
+
mode_label = "Computed (MAX)"
|
|
1005
|
+
else:
|
|
1006
|
+
max_ti = max(ti_scores, default=Decimal("0"))
|
|
1007
|
+
sum_children = sum(child_scores, Decimal("0"))
|
|
1008
|
+
computed = max_ti + sum_children
|
|
1009
|
+
mode_label = "Computed (SUM)"
|
|
1010
|
+
|
|
1011
|
+
computed_color_score = get_color_score(computed)
|
|
1012
|
+
computed_level = get_level_from_score(computed)
|
|
1013
|
+
computed_color_level = get_color_level(computed_level)
|
|
1014
|
+
score_table.add_row(
|
|
1015
|
+
f"[bold]{mode_label}[/bold]",
|
|
1016
|
+
f"[bold {computed_color_score}]{_format_score_decimal(computed)}[/bold {computed_color_score}]",
|
|
1017
|
+
f"[bold {computed_color_level}]{computed_level.name}[/bold {computed_color_level}]",
|
|
1018
|
+
"",
|
|
1019
|
+
)
|
|
1020
|
+
renderables.append(score_table)
|
|
1021
|
+
|
|
1022
|
+
# Threat intelligence tree
|
|
1023
|
+
if obs.threat_intels:
|
|
1024
|
+
if renderables:
|
|
1025
|
+
renderables.append("")
|
|
1026
|
+
ti_tree = Tree("[bold]Threat Intelligence[/bold]")
|
|
1027
|
+
_build_ti_tree_for_observable(obs.threat_intels, ti_tree)
|
|
1028
|
+
renderables.append(ti_tree)
|
|
1029
|
+
|
|
1030
|
+
# Relationships tree
|
|
1031
|
+
if depth > 0:
|
|
1032
|
+
rel_tree = _build_relationship_tree_depth(
|
|
1033
|
+
observable_key,
|
|
1034
|
+
all_observables,
|
|
1035
|
+
cv.threat_intel_get_all(),
|
|
1036
|
+
depth,
|
|
1037
|
+
)
|
|
1038
|
+
if renderables:
|
|
1039
|
+
renderables.append("")
|
|
1040
|
+
renderables.append(rel_tree)
|
|
1041
|
+
|
|
1042
|
+
if renderables:
|
|
1043
|
+
rich_print(Panel(Group(*renderables), border_style="magenta", expand=False))
|
|
1044
|
+
else:
|
|
1045
|
+
rich_print("[dim]No score contributions (no threat intel or child observables)[/dim]")
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def display_threat_intel_query(
|
|
1049
|
+
cv: Cyvest,
|
|
1050
|
+
ti_key: str,
|
|
1051
|
+
rich_print: Callable[[Any], None],
|
|
1052
|
+
) -> None:
|
|
1053
|
+
"""
|
|
1054
|
+
Display detailed information about a threat intel entry.
|
|
1055
|
+
|
|
1056
|
+
Args:
|
|
1057
|
+
cv: Cyvest investigation
|
|
1058
|
+
ti_key: Key of the threat intel to display
|
|
1059
|
+
rich_print: Rich renderable handler
|
|
1060
|
+
|
|
1061
|
+
Raises:
|
|
1062
|
+
KeyError: If threat intel not found
|
|
1063
|
+
"""
|
|
1064
|
+
ti = cv.threat_intel_get(ti_key)
|
|
1065
|
+
if ti is None:
|
|
1066
|
+
raise KeyError(f"Threat intel '{ti_key}' not found in investigation.")
|
|
1067
|
+
|
|
1068
|
+
color_level = get_color_level(ti.level)
|
|
1069
|
+
color_score = get_color_score(ti.score)
|
|
1070
|
+
|
|
1071
|
+
# Build info table
|
|
1072
|
+
table = Table(show_header=False, box=None)
|
|
1073
|
+
table.add_column("Field", style="cyan")
|
|
1074
|
+
table.add_column("Value")
|
|
1075
|
+
|
|
1076
|
+
table.add_row("Key", f"[bold]{escape(ti.key)}[/bold]")
|
|
1077
|
+
table.add_row("Source", f"[magenta]{escape(ti.source)}[/magenta]")
|
|
1078
|
+
table.add_row("Observable", f"[cyan]{escape(ti.observable_key)}[/cyan]")
|
|
1079
|
+
table.add_row(
|
|
1080
|
+
"Score",
|
|
1081
|
+
f"[bold {color_score}]{ti.score_display}[/bold {color_score}]",
|
|
1082
|
+
)
|
|
1083
|
+
table.add_row(
|
|
1084
|
+
"Level",
|
|
1085
|
+
f"[bold {color_level}]{ti.level.name}[/bold {color_level}]",
|
|
1086
|
+
)
|
|
1087
|
+
table.add_row("Comment", escape(ti.comment) if ti.comment else "[dim]-[/dim]")
|
|
1088
|
+
|
|
1089
|
+
rich_print(
|
|
1090
|
+
Panel(
|
|
1091
|
+
table,
|
|
1092
|
+
title=f"[bold]Threat Intel:[/bold] {escape(ti.source)}",
|
|
1093
|
+
border_style="magenta",
|
|
1094
|
+
expand=False,
|
|
1095
|
+
)
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
# Taxonomies tree
|
|
1099
|
+
if ti.taxonomies:
|
|
1100
|
+
tax_tree = Tree("[bold]Taxonomies[/bold]")
|
|
1101
|
+
for tax in ti.taxonomies:
|
|
1102
|
+
tax_color = get_color_level(tax.level)
|
|
1103
|
+
tax_label = (
|
|
1104
|
+
f"[{tax_color}]{tax.level.name}[/{tax_color}] {escape(tax.name)}: [bold]{escape(tax.value)}[/bold]"
|
|
1105
|
+
)
|
|
1106
|
+
tax_tree.add(tax_label)
|
|
1107
|
+
rich_print(tax_tree)
|
|
1108
|
+
|
|
1109
|
+
# Extra data
|
|
1110
|
+
if ti.extra:
|
|
1111
|
+
extra_str = _format_extra_data(ti.extra)
|
|
1112
|
+
extra_panel = Panel(
|
|
1113
|
+
extra_str,
|
|
1114
|
+
title="[bold]Extra Data[/bold]",
|
|
1115
|
+
border_style="dim",
|
|
1116
|
+
)
|
|
1117
|
+
rich_print(extra_panel)
|
|
1118
|
+
|
|
1119
|
+
# Show linked observable info
|
|
1120
|
+
obs = cv.observable_get(ti.observable_key)
|
|
1121
|
+
if obs:
|
|
1122
|
+
obs_color_level = get_color_level(obs.level)
|
|
1123
|
+
obs_color_score = get_color_score(obs.score)
|
|
1124
|
+
obs_type_str = obs.obs_type.value if hasattr(obs.obs_type, "value") else str(obs.obs_type)
|
|
1125
|
+
|
|
1126
|
+
obs_table = Table(
|
|
1127
|
+
show_header=False,
|
|
1128
|
+
box=None,
|
|
1129
|
+
)
|
|
1130
|
+
obs_table.add_column("Field", style="cyan")
|
|
1131
|
+
obs_table.add_column("Value")
|
|
1132
|
+
|
|
1133
|
+
obs_table.add_row("Key", f"[bold]{escape(obs.key)}[/bold]")
|
|
1134
|
+
obs_table.add_row("Type", escape(obs_type_str))
|
|
1135
|
+
obs_table.add_row("Value", escape(obs.value))
|
|
1136
|
+
obs_table.add_row(
|
|
1137
|
+
"Score",
|
|
1138
|
+
f"[{obs_color_score}]{obs.score_display}[/{obs_color_score}]",
|
|
1139
|
+
)
|
|
1140
|
+
obs_table.add_row(
|
|
1141
|
+
"Level",
|
|
1142
|
+
f"[{obs_color_level}]{obs.level.name}[/{obs_color_level}]",
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
# Combine table and threat intel tree in one panel
|
|
1146
|
+
if obs.threat_intels:
|
|
1147
|
+
obs_ti_tree = Tree("[bold]Threat Intelligence[/bold]")
|
|
1148
|
+
_build_ti_tree_for_observable(obs.threat_intels, obs_ti_tree)
|
|
1149
|
+
content = Group(obs_table, "", obs_ti_tree)
|
|
1150
|
+
else:
|
|
1151
|
+
content = obs_table
|
|
1152
|
+
|
|
1153
|
+
rich_print(Panel(content, title="[bold]Linked Observable[/bold]", border_style="green", expand=False))
|