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/score.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scoring and propagation engine for Cyvest.
|
|
3
|
+
|
|
4
|
+
Handles automatic score calculation and propagation between threat intelligence,
|
|
5
|
+
observables, and checks based on relationships and hierarchies.
|
|
6
|
+
|
|
7
|
+
Score Calculation:
|
|
8
|
+
- MAX mode: Score = max(all TI scores, all child observable scores)
|
|
9
|
+
- SUM mode: Score = max(TI scores) + sum(child observable scores)
|
|
10
|
+
- Children are determined by relationship direction (OUTBOUND = hierarchical child)
|
|
11
|
+
|
|
12
|
+
Root Observable Barrier:
|
|
13
|
+
The root observable (identified by value="root") acts as a special barrier
|
|
14
|
+
to prevent cross-contamination of observables while maintaining normal scoring:
|
|
15
|
+
|
|
16
|
+
Calculation Phase:
|
|
17
|
+
- Root is SKIPPED when appearing as a child in other observables' calculations
|
|
18
|
+
- This prevents observables linked through root from contaminating each other
|
|
19
|
+
- Root's own score calculation works normally (aggregates its children)
|
|
20
|
+
|
|
21
|
+
Propagation Phase:
|
|
22
|
+
- Root CAN be updated when its children's scores change (normal parent update)
|
|
23
|
+
- Root does NOT propagate upward beyond itself (stops recursive propagation)
|
|
24
|
+
- Root DOES propagate to linked checks (normal check propagation)
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
domain -> root <- ip (domain and ip both have root as child)
|
|
28
|
+
- domain score: only its own TI (root skipped as child)
|
|
29
|
+
- ip score: only its own TI (root skipped as child)
|
|
30
|
+
- root score: max(root TI, domain score, ip score) - normal calculation
|
|
31
|
+
- Result: domain and ip remain isolated despite shared root connection
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from decimal import Decimal
|
|
37
|
+
from enum import Enum
|
|
38
|
+
from typing import TYPE_CHECKING, Literal, Protocol
|
|
39
|
+
|
|
40
|
+
from cyvest.levels import Level, get_level_from_score
|
|
41
|
+
from cyvest.model_enums import PropagationMode, RelationshipDirection
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScoreMode(Enum):
|
|
45
|
+
"""Score calculation mode for observables."""
|
|
46
|
+
|
|
47
|
+
MAX = "max" # Score = max(all TI scores, all child scores)
|
|
48
|
+
SUM = "sum" # Score = max(TI scores) + sum(child scores)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def normalize(cls, value: ScoreMode | Literal["max", "sum"] | str | None) -> ScoreMode:
|
|
52
|
+
"""
|
|
53
|
+
Normalize a score mode value to ScoreMode enum.
|
|
54
|
+
|
|
55
|
+
Accepts enum instances or strings "max"/"sum" (case-insensitive).
|
|
56
|
+
Returns MAX as default for None.
|
|
57
|
+
"""
|
|
58
|
+
if value is None:
|
|
59
|
+
return cls.MAX
|
|
60
|
+
if isinstance(value, cls):
|
|
61
|
+
return value
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
try:
|
|
64
|
+
return cls(value.lower())
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
raise ValueError(f"Invalid score_mode_obs: {value}. Must be 'max' or 'sum'.") from exc
|
|
67
|
+
raise TypeError(f"score_mode_obs must be ScoreMode, str, or None, got {type(value)}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if TYPE_CHECKING:
|
|
71
|
+
from cyvest.model import Check, Observable, ThreatIntel
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ScoreChangeSink(Protocol):
|
|
75
|
+
"""Interface for applying score/level changes with optional side effects."""
|
|
76
|
+
|
|
77
|
+
investigation_id: str
|
|
78
|
+
|
|
79
|
+
def apply_score_change(
|
|
80
|
+
self,
|
|
81
|
+
obj: object,
|
|
82
|
+
new_score: Decimal,
|
|
83
|
+
*,
|
|
84
|
+
reason: str,
|
|
85
|
+
event_type: str = "SCORE_CHANGED",
|
|
86
|
+
contributing_investigation_ids: set[str] | None = None,
|
|
87
|
+
) -> bool: ...
|
|
88
|
+
|
|
89
|
+
def apply_level_change(
|
|
90
|
+
self,
|
|
91
|
+
obj: object,
|
|
92
|
+
level: Level | str,
|
|
93
|
+
*,
|
|
94
|
+
reason: str,
|
|
95
|
+
event_type: str = "LEVEL_UPDATED",
|
|
96
|
+
) -> bool: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ScoreEngine:
|
|
100
|
+
"""
|
|
101
|
+
Engine for managing score calculation and propagation.
|
|
102
|
+
|
|
103
|
+
Handles:
|
|
104
|
+
- Threat intel scores propagating to observables
|
|
105
|
+
- Observable scores propagating through relationships based on direction
|
|
106
|
+
- Observable scores propagating to checks
|
|
107
|
+
|
|
108
|
+
Hierarchical relationships:
|
|
109
|
+
- OUTBOUND (→): source → target, target is a child of source
|
|
110
|
+
- INBOUND (←): source ← target, target is a parent of source
|
|
111
|
+
- BIDIRECTIONAL (↔): excluded from hierarchical propagation
|
|
112
|
+
|
|
113
|
+
Root Observable Barrier:
|
|
114
|
+
The root observable (value="root") has asymmetric barrier behavior:
|
|
115
|
+
|
|
116
|
+
1. In Score Calculation (_calculate_observable_score):
|
|
117
|
+
- When root appears as a child, it is SKIPPED (not included in parent's score)
|
|
118
|
+
- Root's own calculation works normally (aggregates its children)
|
|
119
|
+
- Prevents cross-contamination between observables linked through root
|
|
120
|
+
|
|
121
|
+
2. In Score Propagation (_propagate_to_parent_observables):
|
|
122
|
+
- Root CAN be updated when children change (receives propagation as parent)
|
|
123
|
+
- Root does NOT propagate beyond itself (stops upward recursive propagation)
|
|
124
|
+
- Maintains root as aggregation point while preventing upward flow
|
|
125
|
+
|
|
126
|
+
3. In Check Propagation (_propagate_observable_to_checks):
|
|
127
|
+
- Root propagates to checks normally (no special handling)
|
|
128
|
+
- Ensures checks linked to root receive updated scores
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
score_mode_obs: ScoreMode | Literal["max", "sum"] = ScoreMode.MAX,
|
|
134
|
+
*,
|
|
135
|
+
sink: ScoreChangeSink,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Initialize the score engine.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
score_mode_obs: Observable score calculation mode (MAX or SUM)
|
|
141
|
+
sink: Sink used to apply score/level changes
|
|
142
|
+
"""
|
|
143
|
+
self._observables: dict[str, Observable] = {}
|
|
144
|
+
self._checks: dict[str, Check] = {}
|
|
145
|
+
self._check_keys_by_observable_key: dict[str, set[str]] = {}
|
|
146
|
+
self._score_mode_obs = ScoreMode.normalize(score_mode_obs)
|
|
147
|
+
self._sink = sink
|
|
148
|
+
self._investigation_id = sink.investigation_id
|
|
149
|
+
|
|
150
|
+
def register_observable(self, observable: Observable) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Register an observable for score tracking.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
observable: Observable to register
|
|
156
|
+
"""
|
|
157
|
+
self._observables[observable.key] = observable
|
|
158
|
+
|
|
159
|
+
def register_check(self, check: Check) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Register a check for score tracking.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
check: Check to register
|
|
165
|
+
"""
|
|
166
|
+
self._checks[check.key] = check
|
|
167
|
+
for link in getattr(check, "observable_links", []):
|
|
168
|
+
self._check_keys_by_observable_key.setdefault(link.observable_key, set()).add(check.key)
|
|
169
|
+
|
|
170
|
+
def rebuild_link_index(self) -> None:
|
|
171
|
+
"""Rebuild the check↔observable link index from scratch."""
|
|
172
|
+
self._check_keys_by_observable_key.clear()
|
|
173
|
+
for check in self._checks.values():
|
|
174
|
+
for link in getattr(check, "observable_links", []):
|
|
175
|
+
self._check_keys_by_observable_key.setdefault(link.observable_key, set()).add(check.key)
|
|
176
|
+
|
|
177
|
+
def register_check_observable_link(self, *, check_key: str, observable_key: str) -> None:
|
|
178
|
+
"""Register a newly created check↔observable link in the propagation index."""
|
|
179
|
+
self._check_keys_by_observable_key.setdefault(observable_key, set()).add(check_key)
|
|
180
|
+
|
|
181
|
+
def get_check_links_for_observable(self, observable_key: str) -> list[str]:
|
|
182
|
+
"""Return sorted check keys that currently link to the observable."""
|
|
183
|
+
return sorted(self._check_keys_by_observable_key.get(observable_key, set()))
|
|
184
|
+
|
|
185
|
+
def propagate_threat_intel_to_observable(self, ti: ThreatIntel, observable: Observable) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Propagate threat intel score to its observable.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
ti: The threat intel providing the score
|
|
191
|
+
observable: The observable to update
|
|
192
|
+
"""
|
|
193
|
+
# Special handling for SAFE level threat intel
|
|
194
|
+
# If TI has SAFE level and observable level is lower, upgrade observable to SAFE
|
|
195
|
+
if ti.level == Level.SAFE and observable.level < Level.SAFE:
|
|
196
|
+
self._sink.apply_level_change(
|
|
197
|
+
observable,
|
|
198
|
+
Level.SAFE,
|
|
199
|
+
reason=f"Threat intel update from {ti.source}",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Calculate the new observable score (includes TI scores and child scores)
|
|
203
|
+
new_score = self._calculate_observable_score(observable)
|
|
204
|
+
|
|
205
|
+
if new_score != observable.score:
|
|
206
|
+
self._sink.apply_score_change(
|
|
207
|
+
observable,
|
|
208
|
+
new_score,
|
|
209
|
+
reason=f"Threat intel update from {ti.source}",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Root observable barrier: stop upward propagation at root level
|
|
213
|
+
# Root does NOT propagate to parent observables, but DOES propagate to checks
|
|
214
|
+
if observable.value == "root":
|
|
215
|
+
# Allow root to propagate to checks only
|
|
216
|
+
self._propagate_observable_to_checks(observable.key)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Propagate to parent observables
|
|
220
|
+
self._propagate_to_parent_observables(observable)
|
|
221
|
+
|
|
222
|
+
# Propagate to linked checks
|
|
223
|
+
self._propagate_observable_to_checks(observable.key)
|
|
224
|
+
|
|
225
|
+
def _calculate_observable_score(self, observable: Observable, visited: set[str] | None = None) -> Decimal:
|
|
226
|
+
"""
|
|
227
|
+
Calculate the complete observable score based on threat intel and hierarchical relationships.
|
|
228
|
+
|
|
229
|
+
Hierarchical relationships are determined by direction:
|
|
230
|
+
- OUTBOUND relationships: target is a hierarchical child
|
|
231
|
+
- INBOUND relationships: source has inbound to this observable (source is child)
|
|
232
|
+
- BIDIRECTIONAL relationships: excluded from hierarchy
|
|
233
|
+
|
|
234
|
+
Root Barrier in Calculation:
|
|
235
|
+
When collecting child scores, observables with value="root" (root) are SKIPPED.
|
|
236
|
+
This prevents:
|
|
237
|
+
- Observables from including root's aggregated score when root appears as their child
|
|
238
|
+
- Cross-contamination between separate branches connected through root
|
|
239
|
+
|
|
240
|
+
Example: If parent -> root and root -> child1, child2:
|
|
241
|
+
- parent's child collection will skip root (barrier)
|
|
242
|
+
- root's child collection includes child1, child2 (normal)
|
|
243
|
+
- Result: parent score = only parent's TI (isolated from child1, child2)
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
observable: The observable to calculate score for
|
|
247
|
+
visited: Set of visited observable keys to prevent cycles
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Calculated score based on score_mode:
|
|
251
|
+
- MAX mode: max(all TI scores, all child scores)
|
|
252
|
+
- SUM mode: max(TI scores) + sum(child scores)
|
|
253
|
+
"""
|
|
254
|
+
# Initialize visited set for cycle detection
|
|
255
|
+
if visited is None:
|
|
256
|
+
visited = set()
|
|
257
|
+
|
|
258
|
+
# Prevent infinite recursion
|
|
259
|
+
if observable.key in visited:
|
|
260
|
+
# Return only the observable's own TI score (don't recurse)
|
|
261
|
+
return max((ti.score for ti in observable.threat_intels), default=Decimal("0"))
|
|
262
|
+
|
|
263
|
+
# Mark this observable as visited
|
|
264
|
+
visited.add(observable.key)
|
|
265
|
+
|
|
266
|
+
# Get max threat intel score for this observable
|
|
267
|
+
max_ti_score = max((ti.score for ti in observable.threat_intels), default=Decimal("0"))
|
|
268
|
+
|
|
269
|
+
# Collect child observable scores recursively
|
|
270
|
+
# Children are defined two ways:
|
|
271
|
+
# 1. Targets of this observable's OUTBOUND relationships (source → target)
|
|
272
|
+
# 2. Sources of INBOUND relationships where this observable is the target (child ← this)
|
|
273
|
+
#
|
|
274
|
+
# Root Barrier Note: Root (value="root") is SKIPPED when appearing as a child
|
|
275
|
+
# to prevent cross-contamination between observables linked through root
|
|
276
|
+
child_scores = []
|
|
277
|
+
|
|
278
|
+
# Method 1: OUTBOUND relationships from this observable
|
|
279
|
+
for rel in observable.relationships:
|
|
280
|
+
# Only OUTBOUND relationships define hierarchical children
|
|
281
|
+
if rel.direction == RelationshipDirection.OUTBOUND:
|
|
282
|
+
child = self._observables.get(rel.target_key)
|
|
283
|
+
if child:
|
|
284
|
+
# Root barrier: skip root observable (value="root") when it appears as a child
|
|
285
|
+
if child.value == "root":
|
|
286
|
+
continue
|
|
287
|
+
# Recursively calculate child's complete score
|
|
288
|
+
child_score = self._calculate_observable_score(child, visited)
|
|
289
|
+
child_scores.append(child_score)
|
|
290
|
+
|
|
291
|
+
# Method 2: Other observables with INBOUND relationships pointing to this observable
|
|
292
|
+
# If obs_x has INBOUND to this observable, then obs_x is a child
|
|
293
|
+
for other_key, other_obs in self._observables.items():
|
|
294
|
+
if other_key == observable.key:
|
|
295
|
+
continue
|
|
296
|
+
# Root barrier: skip root observable (value="root") when it appears as a child
|
|
297
|
+
if other_obs.value == "root":
|
|
298
|
+
continue
|
|
299
|
+
for rel in other_obs.relationships:
|
|
300
|
+
if rel.direction == RelationshipDirection.INBOUND and rel.target_key == observable.key:
|
|
301
|
+
# other_obs has INBOUND to this observable, so other_obs is a child
|
|
302
|
+
child_score = self._calculate_observable_score(other_obs, visited)
|
|
303
|
+
child_scores.append(child_score)
|
|
304
|
+
|
|
305
|
+
# Calculate final score based on mode
|
|
306
|
+
if self._score_mode_obs == ScoreMode.MAX:
|
|
307
|
+
# MAX mode: take maximum of all scores (TI + children)
|
|
308
|
+
all_scores = [max_ti_score] + child_scores
|
|
309
|
+
return max(all_scores, default=Decimal("0"))
|
|
310
|
+
else:
|
|
311
|
+
# SUM mode: max TI score + sum of child scores
|
|
312
|
+
sum_children = sum(child_scores, Decimal("0"))
|
|
313
|
+
return max_ti_score + sum_children
|
|
314
|
+
|
|
315
|
+
def _propagate_to_parent_observables(self, observable: Observable) -> None:
|
|
316
|
+
"""
|
|
317
|
+
Propagate score changes up to parent observables.
|
|
318
|
+
|
|
319
|
+
Parents are found through two mechanisms:
|
|
320
|
+
1. INBOUND relationships: source ← target (target is parent)
|
|
321
|
+
2. Other observables with OUTBOUND relationships to this observable (they are parents)
|
|
322
|
+
|
|
323
|
+
Root Barrier in Propagation:
|
|
324
|
+
The root observable (value="root") has special propagation behavior:
|
|
325
|
+
|
|
326
|
+
1. Root CAN be updated (receives propagation):
|
|
327
|
+
- When children's scores change, root is recalculated as a parent
|
|
328
|
+
- Ensures root's aggregated score stays current
|
|
329
|
+
- Uses helper function _update_parent to avoid duplicate processing
|
|
330
|
+
|
|
331
|
+
2. Root does NOT propagate further (stops recursion):
|
|
332
|
+
- After root is updated, propagation stops (no recursive call)
|
|
333
|
+
- Prevents root from updating its parents (if any exist)
|
|
334
|
+
- Maintains root as terminal node in upward propagation
|
|
335
|
+
|
|
336
|
+
This creates an asymmetric barrier: updates flow TO root but not THROUGH root.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
observable: The observable whose score changed
|
|
340
|
+
"""
|
|
341
|
+
processed_parents: set[str] = set()
|
|
342
|
+
|
|
343
|
+
def _update_parent(parent_obs: Observable) -> None:
|
|
344
|
+
"""Helper to update a parent and optionally propagate beyond it."""
|
|
345
|
+
# Avoid double-processing the same parent when reached via both methods
|
|
346
|
+
if parent_obs.key in processed_parents:
|
|
347
|
+
return
|
|
348
|
+
processed_parents.add(parent_obs.key)
|
|
349
|
+
|
|
350
|
+
# Recalculate parent's score
|
|
351
|
+
new_parent_score = self._calculate_observable_score(parent_obs)
|
|
352
|
+
|
|
353
|
+
if new_parent_score != parent_obs.score:
|
|
354
|
+
self._sink.apply_score_change(
|
|
355
|
+
parent_obs,
|
|
356
|
+
new_parent_score,
|
|
357
|
+
reason=f"Child observable {observable.key} updated",
|
|
358
|
+
)
|
|
359
|
+
# Propagate to checks even for root; root barrier only stops upward flow
|
|
360
|
+
self._propagate_observable_to_checks(parent_obs.key)
|
|
361
|
+
|
|
362
|
+
# Stop upward propagation at root (value="root")
|
|
363
|
+
if parent_obs.value != "root":
|
|
364
|
+
self._propagate_to_parent_observables(parent_obs)
|
|
365
|
+
|
|
366
|
+
# Method 1: Find parents through INBOUND relationships
|
|
367
|
+
# For INBOUND: source ← target, target is the parent
|
|
368
|
+
for rel in observable.relationships:
|
|
369
|
+
if rel.direction == RelationshipDirection.INBOUND:
|
|
370
|
+
parent_obs = self._observables.get(rel.target_key)
|
|
371
|
+
if parent_obs and parent_obs.key != observable.key:
|
|
372
|
+
_update_parent(parent_obs)
|
|
373
|
+
|
|
374
|
+
# Method 2: Find observables that have OUTBOUND relationships TO this observable
|
|
375
|
+
# Those observables are parents (they point to this observable as their child)
|
|
376
|
+
for parent_key, parent_obs in self._observables.items():
|
|
377
|
+
if parent_key == observable.key:
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
# Check if parent has an OUTBOUND relationship to this observable
|
|
381
|
+
for rel in parent_obs.relationships:
|
|
382
|
+
if rel.direction == RelationshipDirection.OUTBOUND and rel.target_key == observable.key:
|
|
383
|
+
_update_parent(parent_obs)
|
|
384
|
+
|
|
385
|
+
def _propagate_observable_to_checks(self, observable_key: str) -> None:
|
|
386
|
+
"""
|
|
387
|
+
Propagate observable score to linked checks.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
observable_key: Key of the observable that changed
|
|
391
|
+
"""
|
|
392
|
+
candidate_check_keys = self._check_keys_by_observable_key.get(observable_key, set())
|
|
393
|
+
for check_key in candidate_check_keys:
|
|
394
|
+
check = self._checks.get(check_key)
|
|
395
|
+
if check is None:
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
eligible_observables: list[Observable] = []
|
|
399
|
+
for link in getattr(check, "observable_links", []):
|
|
400
|
+
if link.propagation_mode == PropagationMode.GLOBAL:
|
|
401
|
+
is_effective = True
|
|
402
|
+
else:
|
|
403
|
+
is_effective = check.origin_investigation_id == self._investigation_id
|
|
404
|
+
if not is_effective:
|
|
405
|
+
continue
|
|
406
|
+
obs = self._observables.get(link.observable_key)
|
|
407
|
+
if obs is not None:
|
|
408
|
+
eligible_observables.append(obs)
|
|
409
|
+
|
|
410
|
+
if not eligible_observables:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
max_obs_score = max(obs.score for obs in eligible_observables)
|
|
414
|
+
max_obs_level = max((obs.level for obs in eligible_observables), default=check.level)
|
|
415
|
+
|
|
416
|
+
new_score = max(check.score, max_obs_score)
|
|
417
|
+
if new_score != check.score:
|
|
418
|
+
self._sink.apply_score_change(
|
|
419
|
+
check,
|
|
420
|
+
new_score,
|
|
421
|
+
reason=f"Linked observable {observable_key} updated",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
new_level = max(check.level, max_obs_level)
|
|
425
|
+
if new_level != check.level:
|
|
426
|
+
self._sink.apply_level_change(
|
|
427
|
+
check,
|
|
428
|
+
new_level,
|
|
429
|
+
reason=f"Linked observable {observable_key} updated",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def recalculate_all(self) -> None:
|
|
433
|
+
"""
|
|
434
|
+
Recalculate all scores from scratch.
|
|
435
|
+
|
|
436
|
+
Useful after merging investigations or bulk updates.
|
|
437
|
+
"""
|
|
438
|
+
# First, recalculate all observables from their threat intel and relationships
|
|
439
|
+
for obs in self._observables.values():
|
|
440
|
+
new_score = self._calculate_observable_score(obs)
|
|
441
|
+
if new_score != obs.score:
|
|
442
|
+
self._sink.apply_score_change(
|
|
443
|
+
obs,
|
|
444
|
+
new_score,
|
|
445
|
+
reason="Recalculation",
|
|
446
|
+
event_type="SCORE_RECALCULATED",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Then propagate to all checks (not just MALICIOUS observables)
|
|
450
|
+
for obs in self._observables.values():
|
|
451
|
+
self._propagate_observable_to_checks(obs.key)
|
|
452
|
+
|
|
453
|
+
def get_global_score(self) -> Decimal:
|
|
454
|
+
"""
|
|
455
|
+
Calculate the global investigation score.
|
|
456
|
+
|
|
457
|
+
The global score is the sum of all check scores.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Total investigation score
|
|
461
|
+
"""
|
|
462
|
+
return sum((check.score for check in self._checks.values()), Decimal("0"))
|
|
463
|
+
|
|
464
|
+
def get_global_level(self) -> Level:
|
|
465
|
+
"""
|
|
466
|
+
Calculate the global investigation level.
|
|
467
|
+
|
|
468
|
+
The global level is determined from the global score.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Investigation level
|
|
472
|
+
"""
|
|
473
|
+
return get_level_from_score(self.get_global_score())
|