cyvest 3.0.0__tar.gz → 3.1.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: cyvest
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: Cybersecurity investigation model
5
5
  Keywords: cybersecurity,investigation,threat-intel,security-analysis
6
6
  Author: PakitoSec
@@ -300,7 +300,8 @@ SAFE checks:
300
300
 
301
301
  **Root Observable Barrier:**
302
302
 
303
- The root observable (the investigation's entry point with `value="input-data"`) acts as a special barrier to prevent cross-contamination:
303
+ The root observable (the investigation's entry point with `value="root"`) acts as a special barrier to prevent cross-contamination:
304
+ Its key is derived from type + value (e.g. `obs:file:root` or `obs:artifact:root`).
304
305
 
305
306
  **Barrier as Child** - When root appears as a child of other observables, it is **skipped** in their score calculations.
306
307
 
@@ -271,7 +271,8 @@ SAFE checks:
271
271
 
272
272
  **Root Observable Barrier:**
273
273
 
274
- The root observable (the investigation's entry point with `value="input-data"`) acts as a special barrier to prevent cross-contamination:
274
+ The root observable (the investigation's entry point with `value="root"`) acts as a special barrier to prevent cross-contamination:
275
+ Its key is derived from type + value (e.g. `obs:file:root` or `obs:artifact:root`).
275
276
 
276
277
  **Barrier as Child** - When root appears as a child of other observables, it is **skipped** in their score calculations.
277
278
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cyvest"
3
- version = "3.0.0"
3
+ version = "3.1.0"
4
4
  description = "Cybersecurity investigation model"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.10"
@@ -9,16 +9,11 @@ from logurich import logger
9
9
 
10
10
  from cyvest.cyvest import Cyvest
11
11
  from cyvest.levels import Level
12
- from cyvest.model import (
13
- CheckScorePolicy,
14
- InvestigationWhitelist,
15
- ObservableType,
16
- RelationshipDirection,
17
- RelationshipType,
18
- )
12
+ from cyvest.model import InvestigationWhitelist
13
+ from cyvest.model_enums import CheckScorePolicy, ObservableType, RelationshipDirection, RelationshipType
19
14
  from cyvest.proxies import CheckProxy, ContainerProxy, EnrichmentProxy, ObservableProxy, ThreatIntelProxy
20
15
 
21
- __version__ = "3.0.0"
16
+ __version__ = "3.1.0"
22
17
 
23
18
  logger.disable("cyvest")
24
19
 
@@ -215,16 +215,19 @@ class Cyvest:
215
215
  Returns:
216
216
  The created or existing observable
217
217
  """
218
- obs = Observable(
219
- obs_type=obs_type,
220
- value=value,
221
- internal=internal,
222
- whitelisted=whitelisted,
223
- comment=comment,
224
- extra=extra or {},
225
- score=Decimal(str(score)) if score is not None else Decimal("0"),
226
- level=level if level is not None else Level.INFO,
227
- )
218
+ obs_kwargs: dict[str, Any] = {
219
+ "obs_type": obs_type,
220
+ "value": value,
221
+ "internal": internal,
222
+ "whitelisted": whitelisted,
223
+ "comment": comment,
224
+ "extra": extra or {},
225
+ }
226
+ if score is not None:
227
+ obs_kwargs["score"] = Decimal(str(score))
228
+ if level is not None:
229
+ obs_kwargs["level"] = level
230
+ obs = Observable(**obs_kwargs)
228
231
  # Unwrap tuple - facade returns only Observable, discards deferred relationships
229
232
  obs_result, _ = self._investigation.add_observable(obs)
230
233
  return self._observable_proxy(obs_result)
@@ -303,15 +306,17 @@ class Cyvest:
303
306
  if not observable:
304
307
  return None
305
308
 
306
- ti = ThreatIntel(
307
- source=source,
308
- observable_key=observable_key,
309
- comment=comment,
310
- extra=extra or {},
311
- score=Decimal(str(score)),
312
- level=level if level is not None else Level.INFO,
313
- taxonomies=taxonomies or [],
314
- )
309
+ ti_kwargs: dict[str, Any] = {
310
+ "source": source,
311
+ "observable_key": observable_key,
312
+ "comment": comment,
313
+ "extra": extra or {},
314
+ "score": Decimal(str(score)),
315
+ "taxonomies": taxonomies or [],
316
+ }
317
+ if level is not None:
318
+ ti_kwargs["level"] = level
319
+ ti = ThreatIntel(**ti_kwargs)
315
320
  result = self._investigation.add_threat_intel(ti, observable)
316
321
  return self._threat_intel_proxy(result)
317
322
 
@@ -369,16 +374,20 @@ class Cyvest:
369
374
  Returns:
370
375
  The created check
371
376
  """
372
- check = Check(
373
- check_id=check_id,
374
- scope=scope,
375
- description=description,
376
- comment=comment,
377
- extra=extra or {},
378
- score=Decimal(str(score)) if score is not None else Decimal("0"),
379
- level=level if level is not None else Level.NONE,
380
- score_policy=score_policy if score_policy is not None else CheckScorePolicy.AUTO,
381
- )
377
+ check_kwargs: dict[str, Any] = {
378
+ "check_id": check_id,
379
+ "scope": scope,
380
+ "description": description,
381
+ "comment": comment,
382
+ "extra": extra or {},
383
+ }
384
+ if score is not None:
385
+ check_kwargs["score"] = Decimal(str(score))
386
+ if level is not None:
387
+ check_kwargs["level"] = level
388
+ if score_policy is not None:
389
+ check_kwargs["score_policy"] = score_policy
390
+ check = Check(**check_kwargs)
382
391
  return self._check_proxy(self._investigation.add_check(check))
383
392
 
384
393
  def check_get(self, key: str) -> CheckProxy | None:
@@ -685,13 +694,17 @@ class Cyvest:
685
694
  }
686
695
 
687
696
  def display_summary(
688
- self, show_graph: bool = True, exclude_levels: Level | str | Iterable[Level | str] = Level.NONE
697
+ self,
698
+ show_graph: bool = True,
699
+ exclude_levels: Level | str | Iterable[Level | str] = Level.NONE,
700
+ show_score_history: bool = False,
689
701
  ) -> None:
690
702
  display_summary(
691
703
  self,
692
704
  lambda renderables: logger.rich("INFO", renderables),
693
705
  show_graph=show_graph,
694
706
  exclude_levels=exclude_levels,
707
+ show_score_history=show_score_history,
695
708
  )
696
709
 
697
710
  def display_statistics(self) -> None:
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import threading
11
11
  from copy import deepcopy
12
+ from datetime import datetime, timezone
12
13
  from decimal import Decimal
13
14
  from pathlib import Path
14
15
  from typing import TYPE_CHECKING, Any, Literal, overload
@@ -16,7 +17,8 @@ from typing import TYPE_CHECKING, Any, Literal, overload
16
17
  from logurich import logger
17
18
 
18
19
  from cyvest import keys
19
- from cyvest.levels import Level, get_level_from_score, normalize_level
20
+ from cyvest.level_score_rules import recalculate_level_for_score
21
+ from cyvest.levels import Level, normalize_level
20
22
  from cyvest.model import (
21
23
  Check,
22
24
  CheckScorePolicy,
@@ -753,6 +755,8 @@ class Investigation:
753
755
  root_type: Type of root observable ("file" or "artifact")
754
756
  score_mode: Score calculation mode (MAX or SUM)
755
757
  """
758
+ self._started_at = datetime.now(timezone.utc)
759
+
756
760
  # Object collections
757
761
  self._observables: dict[str, Observable] = {}
758
762
  self._checks: dict[str, Check] = {}
@@ -775,7 +779,7 @@ class Investigation:
775
779
 
776
780
  self._root_observable = Observable(
777
781
  obs_type=obj_type,
778
- value="input-data",
782
+ value="root",
779
783
  internal=False,
780
784
  whitelisted=False,
781
785
  comment="Root observable for investigation",
@@ -962,11 +966,8 @@ class Investigation:
962
966
  # Take the higher score
963
967
  if incoming.score > existing.score:
964
968
  existing.score = incoming.score
965
- # Recalculate level
966
- if not existing._explicit_level:
967
- calculated_level = get_level_from_score(existing.score)
968
- if calculated_level > existing.level:
969
- existing.level = calculated_level
969
+ # Recalculate level from new score (SAFE remains sticky against downgrades)
970
+ existing.level = recalculate_level_for_score(existing.level, existing.score)
970
971
 
971
972
  # Take the higher level
972
973
  if incoming.level > existing.level:
@@ -7,10 +7,12 @@ Provides formatted display of investigation results using the Rich library.
7
7
  from __future__ import annotations
8
8
 
9
9
  from collections.abc import Callable, Iterable
10
+ from datetime import datetime, timezone
10
11
  from decimal import Decimal, InvalidOperation
11
12
  from typing import TYPE_CHECKING, Any
12
13
 
13
14
  from rich.align import Align
15
+ from rich.markup import escape
14
16
  from rich.rule import Rule
15
17
  from rich.table import Table
16
18
  from rich.tree import Tree
@@ -22,11 +24,216 @@ if TYPE_CHECKING:
22
24
  from cyvest.cyvest import Cyvest
23
25
 
24
26
 
27
+ def _normalize_exclude_levels(levels: Level | str | Iterable[Level | str]) -> set[Level]:
28
+ base_excluded: set[Level] = {Level.NONE}
29
+ if levels is None:
30
+ return base_excluded
31
+ if isinstance(levels, (Level, str)):
32
+ normalized_level = normalize_level(levels) if isinstance(levels, str) else levels
33
+ return base_excluded | {normalized_level}
34
+
35
+ collected = list(levels)
36
+ if not collected:
37
+ return set()
38
+
39
+ normalized: set[Level] = set()
40
+ for level in collected:
41
+ normalized.add(normalize_level(level) if isinstance(level, str) else level)
42
+ return base_excluded | normalized
43
+
44
+
45
+ def _sort_key_by_score(item: Any) -> tuple[Decimal, str]:
46
+ score = getattr(item, "score", 0)
47
+ try:
48
+ decimal_score = Decimal(score)
49
+ except (TypeError, ValueError, InvalidOperation):
50
+ decimal_score = Decimal(0)
51
+
52
+ item_id = getattr(item, "check_id", "")
53
+ return (-decimal_score, item_id)
54
+
55
+
56
+ def _get_direction_symbol(rel: Relationship, reversed_edge: bool) -> str:
57
+ """Return an arrow indicating direction relative to traversal."""
58
+ direction = rel.direction
59
+ if isinstance(direction, str):
60
+ try:
61
+ direction = RelationshipDirection(direction)
62
+ except ValueError:
63
+ direction = RelationshipDirection.OUTBOUND
64
+
65
+ symbol_map = {
66
+ RelationshipDirection.OUTBOUND: "→",
67
+ RelationshipDirection.INBOUND: "←",
68
+ RelationshipDirection.BIDIRECTIONAL: "↔",
69
+ }
70
+ symbol = symbol_map.get(direction, "→")
71
+ if reversed_edge and direction != RelationshipDirection.BIDIRECTIONAL:
72
+ symbol = "←" if direction == RelationshipDirection.OUTBOUND else "→"
73
+ return symbol
74
+
75
+
76
+ def _build_observable_tree(
77
+ parent_tree: Tree,
78
+ obs: Any,
79
+ *,
80
+ all_observables: dict[str, Any],
81
+ reverse_relationships: dict[str, list[tuple[Any, Relationship]]],
82
+ visited: set[str],
83
+ rel_info: str = "",
84
+ ) -> None:
85
+ if obs.key in visited:
86
+ return
87
+ visited.add(obs.key)
88
+
89
+ color_level = get_color_level(obs.level)
90
+ color_score = get_color_score(obs.score)
91
+
92
+ generated_by = ""
93
+ if obs.generated_by_checks:
94
+ checks_str = "[cyan], [/cyan]".join(escape(check_id) for check_id in obs.generated_by_checks)
95
+ generated_by = f"[cyan][[/cyan]{checks_str}[cyan]][/cyan] "
96
+
97
+ whitelisted_str = " [green]WHITELISTED[/green]" if obs.whitelisted else ""
98
+
99
+ obs_info = (
100
+ f"{rel_info}{generated_by}[bold]{obs.key}[/bold] "
101
+ f"[{color_score}]{obs.score}[/{color_score}] "
102
+ f"[{color_level}]{obs.level.name}[/{color_level}]"
103
+ f"{whitelisted_str}"
104
+ )
105
+
106
+ child_tree = parent_tree.add(obs_info)
107
+
108
+ # Add outbound children
109
+ for rel in obs.relationships:
110
+ child_obs = all_observables.get(rel.target_key)
111
+ if child_obs:
112
+ direction_symbol = _get_direction_symbol(rel, reversed_edge=False)
113
+ rel_label = f"[dim]{rel.relationship_type_name}[/dim] {direction_symbol} "
114
+ _build_observable_tree(
115
+ child_tree,
116
+ child_obs,
117
+ all_observables=all_observables,
118
+ reverse_relationships=reverse_relationships,
119
+ visited=visited,
120
+ rel_info=rel_label,
121
+ )
122
+
123
+ # Add inbound children (observables pointing to this one)
124
+ for source_obs, rel in reverse_relationships.get(obs.key, []):
125
+ if source_obs.key == obs.key:
126
+ continue
127
+ direction_symbol = _get_direction_symbol(rel, reversed_edge=True)
128
+ rel_label = f"[dim]{rel.relationship_type_name}[/dim] {direction_symbol} "
129
+ _build_observable_tree(
130
+ child_tree,
131
+ source_obs,
132
+ all_observables=all_observables,
133
+ reverse_relationships=reverse_relationships,
134
+ visited=visited,
135
+ rel_info=rel_label,
136
+ )
137
+
138
+
139
+ def _render_score_history_table(
140
+ *,
141
+ rich_print: Callable[[Any], None],
142
+ title: str,
143
+ groups: Iterable[tuple[str, Iterable[Any]]],
144
+ started_at: datetime | None,
145
+ ) -> None:
146
+ materialized_groups: list[tuple[str, list[Any]]] = [(name, list(items)) for name, items in groups]
147
+
148
+ def _coerce_utc(value: datetime) -> datetime:
149
+ if value.tzinfo is None:
150
+ return value.replace(tzinfo=timezone.utc)
151
+ return value.astimezone(timezone.utc)
152
+
153
+ def _format_elapsed(total_seconds: float) -> str:
154
+ total_ms = int(round(total_seconds * 1000))
155
+ if total_ms < 0:
156
+ total_ms = 0
157
+ hours, rem_ms = divmod(total_ms, 3_600_000)
158
+ minutes, rem_ms = divmod(rem_ms, 60_000)
159
+ seconds, ms = divmod(rem_ms, 1000)
160
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{ms:03d}"
161
+
162
+ table = Table(title=title, show_lines=False)
163
+ table.add_column("Item")
164
+ table.add_column("#", justify="right")
165
+ table.add_column("Elapsed", style="dim")
166
+ table.add_column("Score", justify="center")
167
+ table.add_column("Level", justify="center")
168
+ table.add_column("Reason")
169
+
170
+ effective_start = _coerce_utc(started_at) if started_at is not None else None
171
+ if effective_start is None:
172
+ earliest: datetime | None = None
173
+ for _group_name, items in materialized_groups:
174
+ for item in items:
175
+ for change in item.get_score_history():
176
+ ts = _coerce_utc(change.timestamp)
177
+ if earliest is None or ts < earliest:
178
+ earliest = ts
179
+ effective_start = earliest
180
+
181
+ has_history = False
182
+ for group_name, items in materialized_groups:
183
+ item_color = "cyan" if group_name.lower() == "observable" else "magenta"
184
+ for item in items:
185
+ score_history = sorted(item.get_score_history(), key=lambda change: change.timestamp)
186
+ if not score_history:
187
+ continue
188
+
189
+ if has_history:
190
+ table.add_section()
191
+
192
+ for idx, change in enumerate(score_history, start=1):
193
+ has_history = True
194
+
195
+ change_timestamp = _coerce_utc(change.timestamp)
196
+ old_score_color = get_color_score(change.old_score)
197
+ new_score_color = get_color_score(change.new_score)
198
+ score_str = (
199
+ f"[{old_score_color}]{change.old_score}[/{old_score_color}] "
200
+ f"→ "
201
+ f"[{new_score_color}]{change.new_score}[/{new_score_color}]"
202
+ )
203
+
204
+ old_level_color = get_color_level(change.old_level)
205
+ new_level_color = get_color_level(change.new_level)
206
+ level_str = (
207
+ f"[{old_level_color}]{change.old_level.name}[/{old_level_color}] "
208
+ f"→ "
209
+ f"[{new_level_color}]{change.new_level.name}[/{new_level_color}]"
210
+ )
211
+
212
+ reason = escape(change.reason) if change.reason else "[dim]-[/dim]"
213
+ elapsed = ""
214
+ if effective_start is not None:
215
+ elapsed = _format_elapsed((change_timestamp - effective_start).total_seconds())
216
+
217
+ item_cell = f"[{item_color}]{escape(item.key)}[/{item_color}]"
218
+ table.add_row(
219
+ item_cell,
220
+ str(idx),
221
+ elapsed,
222
+ score_str,
223
+ level_str,
224
+ reason,
225
+ )
226
+
227
+ table.caption = "No score changes recorded." if not has_history else ""
228
+ rich_print(table)
229
+
230
+
25
231
  def display_summary(
26
232
  cv: Cyvest,
27
233
  rich_print: Callable[[Any], None],
28
234
  show_graph: bool = True,
29
235
  exclude_levels: Level | str | Iterable[Level | str] = Level.NONE,
236
+ show_score_history: bool = False,
30
237
  ) -> None:
31
238
  """
32
239
  Display a comprehensive summary of the investigation using Rich.
@@ -36,26 +243,10 @@ def display_summary(
36
243
  rich_print: A rich renderable handler that is called with renderables for output
37
244
  show_graph: Whether to display the observable graph
38
245
  exclude_levels: Level(s) to omit from the report (default: Level.NONE)
246
+ show_score_history: Whether to display score change history for observables and checks (default: False)
39
247
  """
40
248
 
41
- def _normalize_exclude(levels: Level | str | Iterable[Level | str]) -> set[Level]:
42
- base_excluded: set[Level] = {Level.NONE}
43
- if levels is None:
44
- return base_excluded
45
- if isinstance(levels, (Level, str)):
46
- normalized_level = normalize_level(levels) if isinstance(levels, str) else levels
47
- return base_excluded | {normalized_level}
48
-
49
- collected = list(levels)
50
- if not collected:
51
- return set()
52
-
53
- normalized: set[Level] = set()
54
- for level in collected:
55
- normalized.add(normalize_level(level) if isinstance(level, str) else level)
56
- return base_excluded | normalized
57
-
58
- resolved_excluded_levels = _normalize_exclude(exclude_levels)
249
+ resolved_excluded_levels = _normalize_exclude_levels(exclude_levels)
59
250
 
60
251
  all_checks = cv.get_all_checks().values()
61
252
  filtered_checks = [c for c in all_checks if c.level not in resolved_excluded_levels]
@@ -72,17 +263,6 @@ def display_summary(
72
263
  f"Applied: {applied_checks}",
73
264
  ]
74
265
 
75
- def sort_key_by_score(check: Any) -> tuple[Decimal, str]:
76
- score = getattr(check, "score", 0)
77
- try:
78
- decimal_score = Decimal(score)
79
- except (TypeError, ValueError, InvalidOperation):
80
- decimal_score = Decimal(0)
81
-
82
- # Return tuple: (-score for descending, check_id for ascending alphabetically)
83
- check_id = getattr(check, "check_id", "")
84
- return (-decimal_score, check_id)
85
-
86
266
  table = Table(
87
267
  title="Investigation Report",
88
268
  caption=" | ".join(caption_parts),
@@ -107,7 +287,7 @@ def display_summary(
107
287
  for scope_name, checks in checks_by_scope.items():
108
288
  scope_rule = Align(f"[bold magenta]{scope_name}[/bold magenta]", align="left")
109
289
  table.add_row(scope_rule, "-", "-")
110
- checks = sorted(checks, key=sort_key_by_score)
290
+ checks = sorted(checks, key=_sort_key_by_score)
111
291
  for check in checks:
112
292
  color_level = get_color_level(check.level)
113
293
  color_score = get_color_score(check.score)
@@ -144,7 +324,7 @@ def display_summary(
144
324
  checks = [
145
325
  c for c in cv.get_all_checks().values() if c.level == level_enum and c.level not in resolved_excluded_levels
146
326
  ]
147
- checks = sorted(checks, key=sort_key_by_score)
327
+ checks = sorted(checks, key=_sort_key_by_score)
148
328
  if checks:
149
329
  color_level = get_color_level(level_enum)
150
330
  level_rule = Align(
@@ -214,73 +394,34 @@ def display_summary(
214
394
  for rel in source_obs.relationships:
215
395
  reverse_relationships.setdefault(rel.target_key, []).append((source_obs, rel))
216
396
 
217
- def get_direction_symbol(rel: Relationship, reversed_edge: bool) -> str:
218
- """Return an arrow indicating direction relative to traversal."""
219
- direction = rel.direction
220
- if isinstance(direction, str):
221
- try:
222
- direction = RelationshipDirection(direction)
223
- except ValueError:
224
- direction = RelationshipDirection.OUTBOUND
225
-
226
- symbol_map = {
227
- RelationshipDirection.OUTBOUND: "→",
228
- RelationshipDirection.INBOUND: "←",
229
- RelationshipDirection.BIDIRECTIONAL: "↔",
230
- }
231
- symbol = symbol_map.get(direction, "→")
232
- if reversed_edge and direction != RelationshipDirection.BIDIRECTIONAL:
233
- symbol = "←" if direction == RelationshipDirection.OUTBOUND else "→"
234
- return symbol
235
-
236
- def build_tree(parent_tree: Tree, obs: Observable, visited: set[str], rel_info: str = "") -> None:
237
- if obs.key in visited:
238
- return
239
- visited.add(obs.key)
240
-
241
- # Format observable info
242
- color_level = get_color_level(obs.level)
243
- color_score = get_color_score(obs.score)
244
-
245
- generated_by = ""
246
- if obs._generated_by_checks:
247
- checks_str = "][cyan], [/cyan][cyan]".join(obs._generated_by_checks)
248
- generated_by = f"[cyan][[/cyan]{checks_str}[cyan]][/cyan] "
249
-
250
- whitelisted_str = " [green]WHITELISTED[/green]" if obs.whitelisted else ""
251
-
252
- obs_info = (
253
- f"{rel_info}{generated_by}[bold]{obs.key}[/bold] "
254
- f"[{color_score}]{obs.score}[/{color_score}] "
255
- f"[{color_level}]{obs.level.name}[/{color_level}]"
256
- f"{whitelisted_str}"
257
- )
258
-
259
- child_tree = parent_tree.add(obs_info)
260
-
261
- # Add outbound children
262
- for rel in obs.relationships:
263
- child_obs = all_observables.get(rel.target_key)
264
- if child_obs:
265
- direction_symbol = get_direction_symbol(rel, reversed_edge=False)
266
- rel_label = f"[dim]{rel.relationship_type_name}[/dim] {direction_symbol} "
267
- build_tree(child_tree, child_obs, visited, rel_label)
268
-
269
- # Add inbound children (observables pointing to this one)
270
- for source_obs, rel in reverse_relationships.get(obs.key, []):
271
- if source_obs.key == obs.key:
272
- continue
273
- direction_symbol = get_direction_symbol(rel, reversed_edge=True)
274
- rel_label = f"[dim]{rel.relationship_type_name}[/dim] {direction_symbol} "
275
- build_tree(child_tree, source_obs, visited, rel_label)
276
-
277
397
  # Start from root
278
398
  root = cv.observable_get_root()
279
399
  if root:
280
- build_tree(tree, root, set())
400
+ _build_observable_tree(
401
+ tree,
402
+ root,
403
+ all_observables=all_observables,
404
+ reverse_relationships=reverse_relationships,
405
+ visited=set(),
406
+ )
281
407
 
282
408
  rich_print(tree)
283
409
 
410
+ if show_score_history:
411
+ all_observables = cv.get_all_observables()
412
+ all_checks = cv.get_all_checks()
413
+ if all_observables or all_checks:
414
+ started_at = getattr(getattr(cv, "_investigation", None), "_started_at", None)
415
+ _render_score_history_table(
416
+ rich_print=rich_print,
417
+ title="Score History",
418
+ groups=[
419
+ ("Observable", [obs for _key, obs in sorted(all_observables.items())]),
420
+ ("Check", [check for _key, check in sorted(all_checks.items())]),
421
+ ],
422
+ started_at=started_at,
423
+ )
424
+
284
425
 
285
426
  def display_statistics(cv: Cyvest, rich_print: Callable[[Any], None]) -> None:
286
427
  """