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/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))