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.
- {cyvest-3.0.0 → cyvest-3.1.0}/PKG-INFO +3 -2
- {cyvest-3.0.0 → cyvest-3.1.0}/README.md +2 -1
- {cyvest-3.0.0 → cyvest-3.1.0}/pyproject.toml +1 -1
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/__init__.py +3 -8
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/cyvest.py +43 -30
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/investigation.py +8 -7
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/io_rich.py +233 -92
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/io_serialization.py +55 -6
- cyvest-3.1.0/src/cyvest/level_score_rules.py +68 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/model.py +51 -164
- cyvest-3.1.0/src/cyvest/model_enums.py +79 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/model_schema.py +2 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/proxies.py +13 -38
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/score.py +14 -14
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/cli.py +0 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/io_schema.py +0 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/io_visualization.py +0 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/keys.py +0 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/levels.py +0 -0
- {cyvest-3.0.0 → cyvest-3.1.0}/src/cyvest/stats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: cyvest
|
|
3
|
-
Version: 3.
|
|
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="
|
|
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="
|
|
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
|
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
219
|
-
obs_type
|
|
220
|
-
value
|
|
221
|
-
internal
|
|
222
|
-
whitelisted
|
|
223
|
-
comment
|
|
224
|
-
extra
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
307
|
-
source
|
|
308
|
-
observable_key
|
|
309
|
-
comment
|
|
310
|
-
extra
|
|
311
|
-
score
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
373
|
-
check_id
|
|
374
|
-
scope
|
|
375
|
-
description
|
|
376
|
-
comment
|
|
377
|
-
extra
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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,
|
|
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.
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
"""
|