cyvest 4.4.0__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.

Potentially problematic release.


This version of cyvest might be problematic. Click here for more details.

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